commit 57bd6c8e6a33aeac099004ee9433bb8916ecf73c Author: Martin Donnelly Date: Sun Jun 24 21:15:03 2018 +0100 init diff --git a/.arcconfig b/.arcconfig new file mode 100644 index 0000000..97a5c94 --- /dev/null +++ b/.arcconfig @@ -0,0 +1,11 @@ +{ + "phabricator.uri" : "http://10.0.10.242", + + "load": [ + ".arcanist-extensions/tap_test_engine" + ], + + "unit.engine": "TAPTestEngine", + "unit.engine.tap.command": "gulp --cwd node_server --reporter tap test", + "unit.engine.tap.eol": "\n" +} \ No newline at end of file diff --git a/.arclint b/.arclint new file mode 100644 index 0000000..5382453 --- /dev/null +++ b/.arclint @@ -0,0 +1,18 @@ +{ + "linters": { + "eslint-regex-based": { + "type": "script-and-regex", + "include": "(\\.js?$)", + "exclude": [], + "script-and-regex.script": "node eslint-for-arc.js", + "script-and-regex.regex": "/^(?P.*): line (?P[0-9]*), col (?P[0-9]*), ((?PWarning)|(?PError)) - (?P.*) \\((?P[a-z-\\/]+)\\)$/m" + }, + "nsp-regex-based": { + "type": "script-and-regex", + "include": "(package.json$)", + "exclude": [], + "script-and-regex.script": "node nsp-for-arc.js", + "script-and-regex.regex": "/^ (?P\\S* +\\S*) +(?P\\S*) +(?P(?>\\S*(?> > )?)*) +(?Phttps:\\S*) *$/m" + } + } +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..b75f20d --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,100 @@ +module.exports = { + "extends": [ + "canonical", + "canonical/lodash", + "canonical/mocha" + ], + "rules": { + // enable additional rules + // + // A reasonable number of these are because we are stuck on Node 4. + // Moving to later versions of node will allow things like await/async etc. + // + "arrow-body-style": 0, + "jsdoc/require-description-complete-sentence": 0, + "filenames/match-regex": 0, + "func-style": 0, + "id-length": [ + 1, + { + "exceptions": [ + "i", + "j", + "Q", + "P", + "R", + "$", + "_" + ], + "max": 50, + "min": 2 + } + ], + "id-match": [ + 2, + "(^[$A-Za-z]+(?:[A-Z][a-z]*)*\\d*$)|(^[A-Z]+(_[A-Z]+)*(_\\d$)*$)|(^(_|\\$)$)", + { + "onlyDeclarations": true, + "properties": false + } + ], + "indent": [ + 2, + 4, + { + "SwitchCase": 1 + } + ], + "import/no-commonjs": 0, + "import/no-dynamic-require": 0, + "import/unambiguous": 0, + "import/order": 0, + "linebreak-style": 0, + "lines-around-directive": 0, + "line-comment-position": 0, + "newline-after-var": 0, + "newline-before-return": 0, + "no-extra-parens": 0, + "no-inline-comments": 0, + "no-multi-spaces": [ + 2, + { + "ignoreEOLComments": true + } + ], + "no-param-reassign": 0, + "no-trailing-spaces": [ + 2, + { + "skipBlankLines": false, + "ignoreComments": false + } + ], + "no-use-before-define": [ + 2, + { + "functions": false + } + ], + "object-shorthand": 1, + "promise/prefer-await-to-then": 0, + "promise/prefer-await-to-callbacks": 0, + "sort-keys": 0, + "space-before-function-paren": [ + 2, + { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + } + ], + "strict": 0 + }, + "settings": { + "jsdoc": { + "additionalTagNames": { + "customTags": ["ngInject"] + } + } + } +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..83dfc87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# All other files are subjected to the usual algorithm to determine +# whether a file is a binary file or a text file, respecting +# "core.eol" for all files detected as text files. +# "core.autocrlf", if set, will force the conversion to/from CRLF +# automatically as necessary for text files. +* text=auto + +# package.json (from NPM) is in lf not crlf +package.json eol=lf +package-lock.json eol=lf + +# shell scripts should be LF +*.sh eol=lf + +# jade/pug files are lf too +# see: https://github.com/jadejs/jade/issues/1683 +*.jade eol=lf +*.pug eol=lf + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4f4ae9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea/ +node_server/node_modules/ +/node_server/portal/ +/node_server/docs/ +/node_server/temp/ +tsconfig.json +devenvtemp.txt +testenvtemp.txt +prodenvtemp.txt +version.txt +/node_modules/ +node_server/coverage/ +node_server/email_templates/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6692fd1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule ".arcanist-extensions"] + path = .arcanist-extensions + url = https://github.com/farrago/arcanist-extensions.git + branch = tap-line-endings diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a6dbbf5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM registry.eu-gb.bluemix.net/ibmnode:latest + + ## Add the node server directory. + ADD ./node_server /node_server + + ## Standard updates. Note to pin a version use: package-foo=1.3.* \ + RUN apt-get update -q && apt-get install -y -q \ + curl \ + graphicsmagick \ + && apt-get clean -q \ + && rm -rf /var/lib/apt/lists/* + + ## Enhance default password rules. + RUN sed -i 's/^PASS_MIN_DAYS.*/PASS_MIN_DAYS 1/' /etc/login.defs + + ## Enhance default password rules. + RUN echo 'Europe/London' > /etc/timezone + RUN dpkg-reconfigure -f noninteractive tzdata + + ## Expose the appropriate ports. + EXPOSE 80 + + ## Install the node modules. + WORKDIR /node_server + RUN npm install + + ## Execute the code. + CMD ["node", "node_server.js"] diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml new file mode 100644 index 0000000..a261736 --- /dev/null +++ b/bitbucket-pipelines.yml @@ -0,0 +1,76 @@ +# This is based on the sample build configuration for JavaScript. +# Check our guides at https://confluence.atlassian.com/x/14UWN for more examples. +# Only use spaces to indent your .yml configuration. +# ----- +# You can specify a custom docker image from Docker Hub as your build environment. +image: node:8 + +pipelines: + default: + - step: + # + # Validate swagger definitions against the swagger 2.0 JSON schema spec + # NOTE: we have to download it manually as ajv-cli only supports local + # schemas at present. + # + name: "Swagger schema validation" + caches: + - node + script: + - npm install -g ajv-cli + - TEMPLATEFILE=`mktemp` || exit 1 + - wget -q http://json.schemastore.org/swagger-2.0 -O $TEMPLATEFILE + - DEREFEDSWAGGERFILE=`mktemp` || exit 1 + - npm install -g json-refs + - json-refs resolve node_server/swagger_api/api_swagger_def.json > $DEREFEDSWAGGERFILE + - ajv test -s $TEMPLATEFILE -d $DEREFEDSWAGGERFILE --valid --errors=json + - ajv test -s $TEMPLATEFILE -d node_server/integration_api/integration_swagger_def.json --valid --errors=json + - step: + # + # Run ESLint against all the JS files that were changed in this branch. + # + # The linting is run using a script as its a little too complex for + # standalone commands. + # + # Note that this will also run on master when changes are merged in, but + # as `HEAD` and `master` will be the same revision there will be no files + # in the list of changes and ESLint won't be run. + # + # + name: "ESLint" + caches: + - node + script: + # Install packages to get our expected version of ESLint and related configs + # pipeline runs as root, but npm doesn't like installing packages as + # root for security reasons. As this is just CI we allow it, using + # unsafe-perm to make npm accept it. + # + - npm install --unsafe-perm + # Run the tests + - chmod +x ./tools/bitbucket-pipeline-scripts/eslint-changes.sh + - ./tools/bitbucket-pipeline-scripts/eslint-changes.sh + - step: + # + # All unit tests are run every time in case a change has an unexpected + # effect on other areas. + # + name: "Unit tests" + caches: + - node + script: + - npm install -g gulp + # + # pipeline runs as root, but npm doesn't like installing packages as + # root for security reasons. As this is just CI we allow it, using + # unsafe-perm to make npm accept it. + # + - npm install --unsafe-perm + # + # As described in the docs, we need to use the mccha-junit-reporter + # to output results in a format pipelines understands, plus set an + # environment variable to put them in a location that it looks in. + # See: + # https://confluence.atlassian.com/bitbucket/test-reporting-in-pipelines-939708543.html + # + - MOCHA_FILE=./test-reports/[hash].xml gulp --cwd node_server test --reporter mocha-junit-reporter \ No newline at end of file diff --git a/eslint-for-arc.js b/eslint-for-arc.js new file mode 100644 index 0000000..782b26c --- /dev/null +++ b/eslint-for-arc.js @@ -0,0 +1,36 @@ +/** + * @fileOverview There is a compatibility problem between eslint and arcanist. + * ESLint returns a non-zero code on exit if there are any lint errors + * in the file. But `arc` regex liniters expect to only get an + * non-zero exit code on true errors (e.g. eslint config errors). + * + * So we use this file and the ESLint API to build a version that + * only returns non-zero on actual errors (which will be unhandled + * exceptions, so we don't actually need to do anything to achieve) + */ +const CLIEngine = require('eslint').CLIEngine; + +const cli = new CLIEngine(); + +/** + * Get the file to lint from the command line. The command line is always + * argv[0] - node exe + * argv[1] - this script + * argv[2] - the file passed on the command line + */ +if (process.argv.length !== 3) { + throw new Error('Must pass exactly 1 file on the command line'); +} + +const filename = process.argv[2]; + +// Lint the file passed in +const report = cli.executeOnFiles([filename]); + +// Get the compact formatter +const formatter = cli.getFormatter('compact'); + +// Output to stdout so it can be picked up by the arcanist regex +process.stdout.write(formatter(report.results)); + +// Allow the program to exit normally, which will return code 0 diff --git a/node_server/.jscsrc b/node_server/.jscsrc new file mode 100644 index 0000000..9c0d929 --- /dev/null +++ b/node_server/.jscsrc @@ -0,0 +1,8 @@ +{ + "excludeFiles": ["node_modules/**", "bower_components/**"], + "preset": "google", + "validateIndentation": 4, + "maximumLineLength": 140, + "maxErrors": 1000, + "requireCamelCaseOrUpperCaseIdentifiers": "ignoreProperties" +} diff --git a/node_server/.jshintrc b/node_server/.jshintrc new file mode 100644 index 0000000..c3c07c2 --- /dev/null +++ b/node_server/.jshintrc @@ -0,0 +1,58 @@ +{ + "bitwise": true, + "camelcase": true, + "curly": true, + "eqeqeq": true, + "es3": false, + "forin": true, + "freeze": true, + "immed": true, + "indent": 4, + "latedef": "nofunc", + "newcap": true, + "noarg": true, + "noempty": true, + "nonbsp": true, + "nonew": true, + "plusplus": false, + "quotmark": "single", + "undef": true, + "unused": false, + "strict": false, + "maxparams": 10, + "maxdepth": 5, + "maxstatements": 50, + "maxcomplexity": 8, + "maxlen": 140, + + "asi": false, + "boss": false, + "debug": false, + "eqnull": true, + "esnext": true, + "evil": false, + "expr": false, + "funcscope": false, + "globalstrict": false, + "iterator": false, + "lastsemic": false, + "laxbreak": false, + "laxcomma": false, + "loopfunc": true, + "maxerr": 50, + "moz": false, + "multistr": false, + "notypeof": false, + "proto": false, + "scripturl": false, + "shadow": false, + "sub": true, + "supernew": false, + "validthis": false, + "noyield": false, + + "node": true, + + "globals": { + } +} diff --git a/node_server/ComServe/auth-promises.js b/node_server/ComServe/auth-promises.js new file mode 100644 index 0000000..a03dc57 --- /dev/null +++ b/node_server/ComServe/auth-promises.js @@ -0,0 +1,21 @@ +/** + * @file This file wraps the functions in auth.js with promises for simpler + * use in promises and async/await + */ + +const Q = require('q'); +const auth = require('./auth.js'); + +module.exports = { + validSession: (...args) => Q.nfapply(auth.validSession, args), + validateCurrentSession: (...args) => Q.nfapply(auth.validateCurrentSession, args), + checkHMAC: (...args) => Q.nfapply(auth.checkHMAC, args), + checkClientPassword: (...args) => Q.nfapply(auth.checkClientPassword, args), + + /** + * Non-promise functions for compatibility + */ + respond: auth.respond, + checkClientStatus: auth.checkClientStatus, + checkDeviceStatus: auth.checkDeviceStatus +}; diff --git a/node_server/ComServe/auth.js b/node_server/ComServe/auth.js new file mode 100644 index 0000000..520151b --- /dev/null +++ b/node_server/ComServe/auth.js @@ -0,0 +1,941 @@ +/** + * @fileOverview Node.js Authorisation Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var valid = require(global.pathPrefix + 'valid.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var log = require(global.pathPrefix + 'log.js'); +var config = require(global.configFile); +var templates = require(global.pathPrefix + '../utils/templates.js'); +var formattingUtils = require(global.pathPrefix + '../utils/formatting.js'); +var crypto = require('crypto'); +var async = require('async'); +var moment = require('moment'); + +/** + * This function checks the client status for any blocking flags. It will return an error if the following are true: + * 1) The client is not verified. + * 2) The client is barred. + * + * @type {function} checkClientStatus + * @param {!string} ClientStatus - The ClientStatus flag from the client document. + */ +exports.checkClientStatus = function(ClientStatus) { + /** + * Valid session. Check client status. + */ + if (!utils.bitsAllSet(ClientStatus, utils.ClientEmailVerifiedMask)) { + return utils.createError(114, 'Client e-mail has not been verified - please click the link.'); + } + if (utils.bitsAllSet(ClientStatus, utils.ClientBarredMask)) { + return utils.createError(117, 'Client barred by Comcarde.'); + } + return null; +}; + +/** + * This function checks the device status for any blocking flags. It will return an error if the following are true: + * 1) The device is not verified. + * 2) The device is not authorised. + * 3) The device is suspended. + * 4) The device is barred. + * + * @type {function} checkDeviceStatus + * @param {!string} DeviceStatus - The DeviceStatus flag from the device document. + */ +exports.checkDeviceStatus = function(DeviceStatus) { + /** + * Valid session. Check device status. + */ + if (!utils.bitsAllSet(DeviceStatus, utils.DeviceRegister2Mask)) { + return utils.createError(109, 'Device not verified - SMS not confirmed.'); + } + if (!utils.bitsAllSet(DeviceStatus, utils.DeviceRegister3Mask)) { + return utils.createError(110, 'Device not authorised - PIN not set.'); + } + if (utils.bitsAllSet(DeviceStatus, utils.DeviceSuspendedMask)) { + return utils.createError(111, 'Device suspended by the user.'); + } + if (utils.bitsAllSet(DeviceStatus, utils.DeviceBarredMask)) { + return utils.createError(112, 'Device barred by Comcarde.'); + } + return null; +}; + +/** + * This function needs to be called with all server requests. It checks the user is currently logged in + * and if they are, it returns their client and device details. It requires two parameters: + * + * @type {function} validateCurrentSession + * @param {!string} DeviceToken - The token assigned to the device at registration. + * @param {!string} SessionToken - The token returned at login. Note that this is valid for ~5 minutes only. + * Calling this function extends the SessionToken's life. + * @param {!function} next - Not optional and should contain the code to be subsequently executed. + */ +exports.validateCurrentSession = function(DeviceToken, SessionToken, next) { + /** + * Valid input. Check to see if the database is online. + * Cyclomatic complexity is known to be high for this function. + */ + //jshint -W074 + mainDB.findOneObject(mainDB.collectionDevice, {DeviceToken: DeviceToken}, undefined, false, function(err, existingDevice) { + if (err) { + next(utils.createError(104, 'Database offline.'), null, null); + return; + } + + /** + * No information returned from database. + */ + if (existingDevice === null) { + next(utils.createError(103, 'Cannot find device token.'), null, null); + return; + } + + /** + * Device found. Now check the token. + */ + if (SessionToken !== existingDevice.SessionToken) { + // Session token invalid. + next(utils.createError(107, 'Invalid session token.'), null, null); + return; + } + + /** + * Check the session token expiry. + */ + var timestamp = new Date(); + var expiry = existingDevice.SessionTokenExpiry; + if (timestamp >= expiry) { + // Session token invalid. + next(utils.createError(108, 'Session token expired.'), null, null); + return; + } + + /** + * Check device status. + */ + var currentDeviceStatus = exports.checkDeviceStatus(existingDevice.DeviceStatus); + if (currentDeviceStatus) { + next(currentDeviceStatus, null, null); + return; + } + + /** + * Through device checks. Pull the client. + */ + mainDB.findOneObject(mainDB.collectionClient, {ClientID: existingDevice.ClientID}, undefined, false, + function(err, existingClient) { + /** + * Check for an error. + */ + if (err) { + // Database is not working. + next(utils.createError(105, 'Database offline.'), null, null); + return; + } + + /** + * If null then there is no Client account. + */ + if (existingClient === null) { + // Callback. + next(utils.createError(106, 'Cannot find account.'), null, null); + return; + } + + /** + * Check client status. + */ + var currentClientStatus = exports.checkClientStatus(existingClient.ClientStatus); + if (currentClientStatus) { + next(currentClientStatus, null, null); + return; + } + + /** + * Great. All active. Extend token validity. + */ + var newExpiry = new Date(timestamp); + newExpiry.setMinutes(newExpiry.getMinutes() + utils.sessionTimeout); + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: DeviceToken}, { + $set: { + LastUpdate: timestamp, + SessionTokenExpiry: newExpiry + } + }, + {upsert: false}, false, function(err) { + if (err) { + next(utils.createError(119, 'Database offline.'), null, null); + return; + } + + /** + * Success! + */ + next(null, existingDevice, existingClient); + }); + }); + }); + //jshint +W074 +}; + +/** + * This function needs to be called with all server requests. It checks the user is currently logged in, esures the hmac is OK, + * and if so, it returns their client and device details. It requires multiple parameters: + * + * @type {function} validSession + * @param {!object} res - Response object for returning information. This function will respond directly on error. + * @param {!string} DeviceToken - The token assigned to the device at registration. + * @param {!string} SessionToken - The token returned at login. Note that this is valid for ~5 minutes only. + * Calling this function extends the SessionToken's life. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} hmacData - HMAC information from incoming packet. + * @param {!function} next - Not optional and should contain the code to be subsequently executed. + */ +exports.validSession = function(res, DeviceToken, SessionToken, functionInfo, hmacData, next) { + /** + * First check the session. + */ + exports.validateCurrentSession(DeviceToken, SessionToken, function(err, existingDevice, existingClient) { + if (err) { + res.status(200).json({ + code: ('' + err.code), + info: err.message + }); + log.system( + 'WARNING', + err.message, + functionInfo.name, + err.code, + ('AF [SessionToken ' + SessionToken + ' (DeviceToken ' + DeviceToken + ')]'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + /** + * Call back passing the error. + */ + next(err, null, null); + return; + } + + /** + * Update the hmacData to store the ClientName from the existingClient as that + * is required for the HMAC generation and validation + */ + hmacData.ClientName = existingClient.ClientName; + + /** + * Check the HMAC is fine. + */ + exports.checkHMAC(existingDevice, hmacData, functionInfo.name, function(err) { + if (err) { + res.status(200).json({ + code: ('' + err.code), + info: err.message + }); + log.system( + 'WARNING', + err.message, + functionInfo.name, + err.code, + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + /** + * Call back passing the error. + */ + next(err, null, null); + return; + } + + /** + * All valid. Proceed. + */ + next(null, existingDevice, existingClient); + }); + }); +}; + +/** + * Checks the PIN received from the device. This function will return an error object as the + * first parameter if something went wrong. + * + * @type {function} checkDevicePIN + * @param {!string} deviceAuthorisation - SHA256 of PIN as received from device. + * @param {!object} existingDevice - Existing object in database. + * @param {!object} timestamp - Reference time when clock was pulled. + * @param {!function} next - Function to call when verification complete. + */ +exports.checkDevicePIN = function(deviceAuthorisation, existingDevice, timestamp, next) { + /** + * Check for a locked device. + */ + if (existingDevice.LoginAttempts >= utils.PINLockout) { + next(utils.createError(399, 'Device locked. Please use PIN Reset.')); + return; + } + + /** + * Split up the existing PIN and update if necessary. + */ + var receivedDeviceAuth; + var databaseDeviceAuth; + var authArray = existingDevice.DeviceAuthorisation.split('::'); + async.series([ + function(callback) { + /** + * Find the salt or create a new one if one doesn't exist. + */ + if (authArray[0] === '2') { + /** + * PIN encrypted using PBKDF2. + */ + crypto.pbkdf2(deviceAuthorisation, existingDevice.DeviceSalt, + config.encryptPBKDF2Rounds, config.encryptPBKDF2Bytes, config.encryptPBKDF2Protocol, + function(err, newHash) { + if (err) { + callback(err); + } else { + /** + * Update the database. + */ + receivedDeviceAuth = newHash.toString('hex'); + databaseDeviceAuth = authArray[1]; + callback(null); + } + }); + } else { + /** + * Problem with the encryption string. + */ + callback('Unknown encryption type.'); + } + } + ], + /** + * Final clause which is executed after everything else or when an error is detected. + */ + function(err) { + if (err) { + next(utils.createError(400, ('Error when checking PIN: ' + err))); + return; + } + + /** + * Check that the PIN matches. + */ + if (receivedDeviceAuth !== databaseDeviceAuth) { + /** + * Wrong PIN. Increase the fail count. + */ + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, { + $set: {LastUpdate: timestamp}, + $inc: {LoginAttempts: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + next(utils.createError(401, 'Database offline.')); + return; + } + + /** + * Check for maximum number of retries; if so, lock the account. + */ + if (existingDevice.LoginAttempts === (utils.PINLockout - 1)) { + /** + * Send warning e-mail. + */ + const suspendUrl = formattingUtils.formatPortalUrl('personal/devices'); + var htmlEmail = templates.render('device-locked', { + DeviceNumber: existingDevice.DeviceNumber, + suspendDeviceUrl: suspendUrl + }); + mailer.sendEmailByID(null, existingDevice.ClientID, 'Bridge Device Locked', htmlEmail, 'auth.checkDevicePIN', + function(err) { + if (err) { + next(utils.createError(402, 'Unable to send e-mail.')); + return; + } + + /** + * Tell the user that the device has been locked. + */ + next(utils.createError(403, 'Wrong PIN. ' + utils.PINLockout + + ' failed attempts have locked this device.')); + }); + return; + } + + /** + * Wrong PIN - more attempts left. + */ + next(utils.createError(404, 'Wrong PIN.')); + }); + return; + } + + /** + * PIN matched successfully. + */ + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, { + $set: { + LastUpdate: timestamp, + LoginAttempts: 0 + } + }, + {upsert: false}, false, function(err) { + if (err) { + next(utils.createError(405, 'Database offline.')); + return; + } + + /** + * Success! + */ + next(null); + }); + }); +}; + +/** + * Checks the client password. This function will return an error object as the first parameter if something went wrong. + * + * @type {function} checkClientPassword + * @param {!string} password - SHA256 of password as received from device. + * @param {!object} existingClient - Existing object in database. + * @param {!object} timestamp - Reference time when clock was pulled. + * @param {!function} next - Function to call when verification complete. + */ +exports.checkClientPassword = function(password, existingClient, timestamp, next) { + /** + * Check for a locked account. + */ + if (existingClient.LoginAttempts >= utils.passwordLockout) { + next(utils.createError(406, 'Attempted login to locked account. Please contact Comcarde.')); + return; + } + + /** + * Split up the existing password and update if necessary. + */ + var receivedPassword; + var databasePassword; + var passArray = existingClient.Password.split('::'); + async.series([ + function(callback) { + /** + * Find the salt or create a new one if one doesn't exist. + */ + if (passArray[0] === '2') { + /** + * Password encrypted using PBKDF2. + */ + crypto.pbkdf2(password, existingClient.ClientSalt, + config.encryptPBKDF2Rounds, config.encryptPBKDF2Bytes, config.encryptPBKDF2Protocol, + function(err, newHash) { + if (err) { + callback(err); + } else { + /** + * Update the database. + */ + receivedPassword = newHash.toString('hex'); + databasePassword = passArray[1]; + callback(null); + } + }); + } else { + /** + * Problem with the encryption string. + */ + callback('Unknown encryption type.'); + } + } + ], + /** + * Final clause which is executed after everything else or when an error is detected. + */ + function(err) { + if (err) { + next(utils.createError(407, ('Error when checking password: ' + err))); + return; + } + + /** + * Check that the password matches. + */ + if (receivedPassword !== databasePassword) { + /** + * Wrong password. Increase the fail count. + */ + mainDB.updateObject(mainDB.collectionClient, {ClientID: existingClient.ClientID}, { + $set: {LastUpdate: timestamp}, + $inc: {LoginAttempts: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + next(utils.createError(408, 'Database offline.')); + return; + } + + /** + * Check for maximum number of retries; if so, lock the account. + */ + if (existingClient.LoginAttempts === (utils.passwordLockout - 1)) { + /** + * Send warning e-mail. + */ + var htmlEmail = templates.render('account-locked', { + ClientName: existingClient.ClientName + }); + mailer.sendEmail(null, existingClient.ClientName, 'Bridge Account Locked', + htmlEmail, 'auth.checkClientPassword', + function(err) { + if (err) { + next(utils.createError(409, 'Unable to send e-mail.')); + return; + } + + /** + * Tell the user that the client account has been locked. + */ + next(utils.createError(410, 'Wrong password. ' + utils.passwordLockout + + ' failed attempts have locked the client account.')); + }); + return; + } + + /** + * Wrong password - more attempts left. + */ + next(utils.createError(411, 'Wrong password.')); + }); + return; + } + + /** + * Password matched successfully. Reset login attempts. + */ + mainDB.updateObject(mainDB.collectionClient, {ClientID: existingClient.ClientID}, { + $set: { + LastUpdate: timestamp, + LoginAttempts: 0 + } + }, + {upsert: false}, false, function(err) { + if (err) { + next(utils.createError(412, 'Database offline.')); + return; + } + + /** + * Success! + */ + next(null); + }); + }); +}; + +/** + * Creates a new salt and encrypts the password hash using PBKDF2. + * This function will return an error object as the first parameter if something went wrong. + * + * @type {function} encryptPBKDF2 + * @param {!string} input - SHA256 of input to be encrypted using PBKDF2. + * @param {!function} next - Function to call when verification complete. + * @param {!object} next.err - Error object. null on success. + * @param {!string} next.newSalt - Random salt for encoding. + * @param {!string} next.newHash - Hashed input using new salt. + */ +exports.encryptPBKDF2 = function(input, next) { + /** + * Create a new salt. + */ + crypto.randomBytes(config.encryptPBKDF2Bytes, function(err, salt) { + if (err) { + next(err, null, null); + return; + } + /** + * Success. Encrypt the password. + */ + var newSalt = salt.toString('hex'); + crypto.pbkdf2(input, newSalt, config.encryptPBKDF2Rounds, config.encryptPBKDF2Bytes, config.encryptPBKDF2Protocol, + function(err, hash) { + if (err) { + next(err, null, null); + return; + } + + /** + * All done. Convert the hash and call back with the new values. + */ + var newHash = hash.toString('hex'); + next(null, newSalt, newHash); + }); + }); +}; + +/** + * Reponds with an HTML page. + * + * @type {function} respond + * @param {!object} res - response object. End will be called by this function. + * @param {!int} responseCode - HTML response code. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {!string} code - The code associated with the response, e.g. '235', '10014'. + * @param {!string} fileName - Name of the Pug file that is the HTML source: e.g. 'templates/39_expired_token.pug'. + * @param {!object} data - Parameters used to render the HTML file. + * @param {!string} logType - The log entry type to be recorded - e.g. 'INFO', 'WARNING' etc. + * Omit if no system log entry is needed such as in 'Database offline'. + * @param {!string} infoString - If logType above is present, infoString is will be used as the information to be logged. + * @param {!string} altUser - If logType above is present, altUser can be used to log different user name. If not, 'UU' wil be used. + */ +exports.respondHTML = function(res, responseCode, functionInfo, code, fileName, data, logType, infoString, altUser) { + /** + * Respond to the request. + */ + var toReturn = templates.render(fileName, data); + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end(toReturn); + + /** + * Log what has happened if required. + */ + var logUser = ''; + if (altUser) { + logUser = altUser; + } else { + logUser = 'UU'; + } + if (logType) { + log.system( + logType, + infoString, + functionInfo.name, + code, + logUser, + (functionInfo.remote + ' (' + functionInfo.port + ')')); + } + + /** + * Add HTML page generation details regardless. + */ + log.system( + 'PAGE', + ('Generated file returned [' + fileName + '].'), + functionInfo.name, + code, + logUser, + (functionInfo.remote + ' (' + functionInfo.port + ')')); +}; + +/** + * Uses a passed HMAC key to generate the data packet. + * + * @type {function} respond + * @param {!object} res - response object. End will be called by this function. + * @param {!int} responseCode - HTML response code. + * @param {!object} existingDevice - Existing object in database. If set to null the function works as a normal res.status() call. + * @param {!object} hmacData - hmac information {!address, !method, !body, !ClientName, ?timestamp, ?hmac} + * This function can be used to respond to non hmac calls by adding null in here. + * Typically set existingDevice and hmacData to null together. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {!object} data - Body of the packet as a JSON object. Note that 'info' and 'code' must be present. + * @param {!string} logType - The log entry type to be recorded - e.g. 'INFO', 'WARNING' etc. + * Omit if no system log entry is needed such as in 'Database offline'. + * @param {!string} altString - If logType above is present, altString can be used to log different data than the user receives. + * @param {!string} altUser - If logType above is present, altUser can be used to log different user name. If not, 'UU' wil be used. + * This parameter is ignored if hmacData and existingDevice are present. + * + * Cyclomatic complexity error disabled as it seems unnecessary. + */ +// jshint -W074 +exports.respond = function(res, responseCode, existingDevice, hmacData, functionInfo, data, logType, altString, altUser) { + /** + * Ensure that the function is getting the correct data to process. + * Checking disabled for speed outwith the development environment. + */ + if (config.isDevEnv) { + if (typeof data !== 'object') { + throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data is not an object.'); + } + if (!('code' in data)) { + throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data is missing code field.'); + } + if (!('info' in data)) { + throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data is missing info field.'); + } + if (typeof data.code !== 'string') { + throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data.code is not a string.'); + } + if (typeof data.info !== 'string') { + throw new Error('auth.respond received bad data from ' + functionInfo.name + ': data.info is not a string.'); + } + } + + /** + * Set up variables for an HMAC based return. + */ + var key = ''; + var DeviceUuid = ''; + if (existingDevice) { + if (functionInfo.name === 'RotateHMAC.process') { + key = existingDevice.PendingHMAC; + DeviceUuid = existingDevice.DeviceUuid; + } else { + key = existingDevice.CurrentHMAC; + } + } + + /** + * If no key is available then there is no HMAC. We do not need to sign. + */ + if ((hmacData === null) || (key === '')) { + /** + * Respond to the request. + */ + res.status(responseCode).json(data); + + /** + * Log what has happened if required. + */ + if (logType) { + var logString = ''; + var logUser = ''; + if (altString) { + logString = altString; + } else { + logString = data.info; + } + if (altUser) { + logUser = altUser; + } else { + logUser = 'UU'; + } + log.system( + logType, + logString, + functionInfo.name, + data.code, + logUser, + (functionInfo.remote + ' (' + functionInfo.port + ')')); + } + return; + } + + /** + * Session token exception for Login1 due to the signing token not yet being saved. + */ + var sessionToken = ''; + if (functionInfo.name === 'Login1.process') { + sessionToken = data.SessionToken; + } else { + sessionToken = existingDevice.SessionToken; + } + + /** + * Process the data and create the hmac. + */ + var timestamp = moment().utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z'; + var text = JSON.stringify(data); + var fullText = hmacData.address + hmacData.method + timestamp + hmacData.ClientName + sessionToken + DeviceUuid + text; + var newkey = new Buffer(key, 'hex'); // Re-encode the key for use with the hmac. + var hmac = crypto.createHmac('sha256', newkey); // Create the HMAC object. + hmac.setEncoding('hex'); // Set encoding. + + /** + * Note that the callback is attached as listener to stream's finish event. + */ + hmac.end(fullText, function() { + /** + * Read the HMAC and respond. + */ + var hash = hmac.read(); + res.writeHead(responseCode, { + 'bridge-hmac': hash, + 'bridge-timestamp': timestamp, + 'Content-Type': 'application/json; charset=utf-8' // Return that this is JSON + }); + res.end(text); + + /** + * Log what has happened if required. + */ + if (logType) { + var logString = ''; + if (altString) { + logString = altString; + } else { + logString = data.info; + } + log.system( + logType, + logString, + functionInfo.name, + data.code, + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + } + }); +}; +// jshint +W074 + +/** + * Checks an incoming HMAC. + * + * @type {function} checkHMAC + * @param {!object} existingDevice - Existing object in database. + * @param {!object} hmacData - hmac information {!address, !method, !body, !ClientName, ?timestamp, ?hmac} + * @param {!string} functionName - The function that called the validation process: e.g. 'PayCodeRequest.process'. + * @param {!function} next - Function that should be called once processing is complete. + * + * Cyclomatic complexity error disabled as it seems unnecessary. + */ +// jshint -W074 +exports.checkHMAC = function(existingDevice, hmacData, functionName, next) { + /** + * Check for HMAC problems first. + */ + if (existingDevice.HMACAttempts >= config.maxHMACAttempts) { + next(utils.createError(458, 'HMAC error: too many failed HMAC attempts.')); + return; + } + + /** + * Only Login1 and RotateHMAC are valid calls if there is a PendingHMAC. + */ + if (existingDevice.PendingHMAC !== '') { + if ((functionName !== 'RotateHMAC.process') && (functionName !== 'Login1.process')) { + next(utils.createError(459, 'HMAC error: HMAC must be rotated using RotateHMAC.')); + return; + } + } + + var key = ''; + var DeviceUuid = ''; + if (functionName === 'RotateHMAC.process') { + key = existingDevice.PendingHMAC; + DeviceUuid = existingDevice.DeviceUuid; + } else { + key = existingDevice.CurrentHMAC; + } + + /** + * If the HMAC key is blank then no HMAC has been issued - re-register the device. + * Note there is one exception and that is on Login1 where there is a PendingHMAC. + */ + if (key === '') { + if ((functionName === 'Login1.process') && (existingDevice.PendingHMAC !== '')) { + next(null); + } else { + next(utils.createError(462, 'HMAC error: No valid HMAC key - please re-register the device.')); + } + return; + } + + /** + * Look for timestamp errors. + */ + var output = ''; + if (!('timestamp' in hmacData)) { + next(utils.createError(446, 'HMAC error: \"bridge-timestamp\" not present.')); + return; + } else { + output = valid.validateFieldTimeStamp(hmacData.timestamp); + if (output) { + next(utils.createError(449, output)); + return; + } + + /** + * Check for desync. + */ + var upperTimestamp = new Date(); + upperTimestamp.setSeconds(upperTimestamp.getSeconds() + config.HMACDesyncThreshold); + if (hmacData.timestamp > upperTimestamp) { + next(utils.createError(451, 'HMAC error: timestamp is in the future.')); + return; + } + var lowerTimestamp = new Date(); + lowerTimestamp.setSeconds(lowerTimestamp.getSeconds() - config.HMACDesyncThreshold); + if (lowerTimestamp > hmacData.timestamp) { + next(utils.createError(452, 'HMAC error: timestamp has expired.')); + return; + } + } + + /** + * Look for hmac errors. + */ + if (!('hmac' in hmacData)) { + next(utils.createError(447, 'HMAC error: \"bridge-hmac\" not present.')); + return; + } else { + output = valid.validateFieldHMAC(hmacData.hmac); + if (output) { + next(utils.createError(450, output)); + return; + } + } + + /** + * Assemble the HMAC. + */ + var fullText = hmacData.address + hmacData.method + hmacData.timestamp + hmacData.ClientName + DeviceUuid + hmacData.body; + var newkey = new Buffer(key, 'hex'); // Re-encode the key for use with the hmac. + var hmac = crypto.createHmac('sha256', newkey); // Create the HMAC object. + hmac.setEncoding('hex'); // Set encoding. + + /** + * Note that the callback is attached as listener to stream's finish event. + */ + hmac.end(fullText, function() { + /** + * Read the HMAC and respond. + */ + var hash = hmac.read(); + if (hash !== hmacData.hmac) { + /** + * HMAC error. Tick up HMAC attempts or bar the device if there have been too many problems. + */ + var timestamp = new Date(); + var toUpdate = { + $set: {LastUpdate: timestamp}, + $inc: {HMACAttempts: 1} + }; + if (existingDevice.HMACAttempts >= (config.maxHMACAttempts - 1)) { + toUpdate.$bit = {DeviceStatus: {or: utils.DeviceBarredMask}}; + } + + /** + * Write this information to the correct device. + */ + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, toUpdate, + {upsert: false}, false, function(err) { + if (err) { + next(utils.createError(460, ('HMAC error: database offline.'))); + return; + } + + /** + * Inform the device of the error. + */ + if (existingDevice.HMACAttempts >= (config.maxHMACAttempts - 1)) { + next(utils.createError(461, ('HMAC error: security check failed and device barred.'))); + } else { + next(utils.createError(448, ('HMAC error: security check failed.'))); + } + }); + } else { + next(null); + } + }); +}; +// jshint +W074 diff --git a/node_server/ComServe/config.js b/node_server/ComServe/config.js new file mode 100644 index 0000000..73ed296 --- /dev/null +++ b/node_server/ComServe/config.js @@ -0,0 +1,262 @@ +/* eslint-disable no-process-env */ +/* eslint-disable no-process-exit */ +/* eslint-disable no-console */ +/** + * @fileOverview Node.js Bridge Server Config for Bridge Pay + * @preserve Copyright 2014-2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ +/* eslint-disable no-process-env, no-process-exit, no-console */ + +/** + * Includes needed for this module. + */ +const crypto = require('crypto'); +const path = require('path'); +const exitCodes = require('../exitcodes.js'); +const packageJson = require('../package.json'); + +/** + * Version information. + */ +exports.EULAVersion = '1.0'; +exports.CCServerReleaseType = 'Beta'; // Options include Alpha, Beta etc. +exports.useHTTPS = true; + +/** + * Ensure NODE_ENV is either 'development' or 'production'. + */ +exports.isDevEnv = (process.env.NODE_ENV === 'development'); +exports.isProdEnv = (process.env.NODE_ENV === 'production'); +if (!exports.isDevEnv && !exports.isProdEnv) { + console.log('NODE_ENV environment variable missing or invalid: must be either \'development\' or \'production\'.'); + process.exit(exitCodes.EXIT_CODE_NO_NODE_ENV); +} + +/** + * Ensure the acquisition server is either 'test' or 'live'. + */ +exports.isTestEnv = (process.env.acquirer === 'test'); +exports.isLiveEnv = (process.env.acquirer === 'live'); +if (!exports.isTestEnv && !exports.isLiveEnv) { + console.log('Acquisition server environment variable missing or invalid: must be either \'test\' or \'live\'.'); + process.exit(exitCodes.EXIT_CODE_NO_ACQUISITION_SERVER); +} + +/** + * Checks that the appropriate environment variable is present and sets the variable if so. + * Error constants. These need to be in config as other code depends on the environment variables being loaded first. + * + * @param {!string} keyName - The key to check for. + * @param {!string} destName - The key to be written in exports. + * @param {varies} [defaultValue] - Default value if one is not provided in the environment + */ +exports.readENVVariable = function(keyName, destName, defaultValue) { + let value = null; + + if (process.env.hasOwnProperty(keyName)) { + value = process.env[keyName]; + } else if (defaultValue === undefined) { + console.log('Missing environment variable: ' + keyName + '\nPlease set the variables to launch the server.'); + process.exit(exitCodes.EXIT_CODE_NO_ENVIRONMENT_VARS); + } else { + value = defaultValue; + } + + /** + * Set the system variable if it has not been set. Do not overwrite. + */ + if (!(destName in exports)) { + exports[destName] = value; + } +}; + +/** + * Read in all the environment variables. + * First key is the environment varable, the second variable is the export name. + */ +exports.readENVVariable('AESKey', 'AESKey'); // e.g. kJq5fW4m/lLG6oLTcM+fPFmlHL9FU9=N +exports.readENVVariable('ServerCommit', 'ServerCommit'); // e.g. 7d33f90 +exports.readENVVariable('PortalCommit', 'PortalCommit'); // e.g. 7d33f90 +exports.readENVVariable('uuid', 'CCUUID'); // e.g. 2478b89c-c165-445b-9ff6-eb0beec60d12 +exports.readENVVariable('HOSTNAME', 'CCServerName'); // e.g. Virtual machine name or ID +exports.readENVVariable('sgroup_name', 'CCServerGroup'); // e.g. Name of the group deployment. +exports.readENVVariable('loadbalancer_vip', 'CCServerIP'); // e.g. 172.31.0.2 +exports.readENVVariable('webAddress', 'CCWebsiteAddress'); // e.g. dev.bridgepay.uk +exports.readENVVariable('forceHTTPS', 'forceHTTPS'); // e.g. 'true' or 'false' +if (exports.forceHTTPS === 'false') { + exports.useHTTPS = false; +} + +/** + * API Service + */ +exports.readENVVariable('serverHttpPort', 'serverHttpPort', 80); + +/** + * Encryption keys. + */ +exports.hashedAESKey = crypto.createHash('sha256').update(exports.AESKey).digest('hex'); // Hashed version of the above key. +exports.pinCryptoVersion = '2'; +exports.passwordCryptoVersion = '2'; +exports.encryptPBKDF2Rounds = 10000; +exports.encryptPBKDF2Bytes = 32; +exports.encryptPBKDF2Protocol = 'sha256'; +exports.HMACBytes = 32; +exports.HMACDesyncThreshold = 120; // Seconds. +exports.maxHMACAttempts = 3; + +/** + * Payments Setup. + * The verification provider can either be Worldpay or Credorax. + * Optionally, a card can be added with zero checking if 'None' is used. + * To shut down verification completely please use a blank string ''. + */ +exports.verificationProvider = 'Worldpay'; +exports.demoCardPAN = '4917610000000000003'; + +/** + * Credorax setup. + */ +exports.readENVVariable('credoraxKey', 'comcardeCipherKey'); +exports.readENVVariable('credoraxMerchantID', 'comcardeMerchantID'); +if (exports.isTestEnv) { + exports.credoraxPrimaryGateway = 'https://intconsole.credorax.com/intenv/service/gateway'; // Normal gateway. + exports.credoraxSecondaryGateway = 'https://intconsole.credorax.com/intenv/service/gateway'; // Failover gateway. +} else { + exports.credoraxPrimaryGateway = 'https://comcarde-eu1.gate.credorax.net/crax_gate/service/gateway'; // Normal gateway. + exports.credoraxSecondaryGateway = 'https://comcarde-na1.gate.credorax.net/crax_gate/service/gateway'; // Failover gateway. +} +exports.credoraxCurrentGateway = exports.credoraxPrimaryGateway; +exports.credoraxChangeoverThreshold = 3; // Number of failed transactions before switchover. +exports.credoraxChangeRate = 1; // Number of failed comms reduced every 15 minutes. +exports.credoraxPrimaryGatewayFailure = 'https://' + exports.CCWebsiteAddress + + ': The primary Credorax gateway has failed. Secondary is now active.'; + +/** + * Worldpay setup. + */ +exports.readENVVariable('worldpayMerchantID', 'worldpayMerchantID'); +exports.readENVVariable('worldpayServiceKey', 'worldpayServiceKey'); +exports.readENVVariable('worldpayClientKey', 'worldpayClientKey'); +exports.worldpayPrimaryGateway = 'https://api.worldpay.com/v1/'; // Normal and test gateways are the same. +exports.worldpayNotificationThreshold = 3; // Number of failed transactions before notification. +exports.worldpayChangeRate = 1; // Number of failed comms reduced every 15 minutes. +exports.worldpayPrimaryGatewayFailure = 'https://' + exports.CCWebsiteAddress + ': The primary Worldpay gateway has failed.'; + +/** + * Database configuration. + */ +exports.readENVVariable('mongoUser', 'mongoUser'); +exports.readENVVariable('mongoPassword', 'mongoPassword'); +exports.readENVVariable('mongoDBAddress', 'mongoDBAddress'); +exports.readENVVariable('mongoUseSSL', 'mongoUseSSL', 'true'); // Env vars are always strings. +exports.mongoUseSSL = (exports.mongoUseSSL !== 'false'); // Coerce to a bool as env is all strings. +if (exports.mongoUseSSL) { + exports.readENVVariable('mongoCACertBase64', 'mongoCACertBase64'); + exports.mongoCA = [Buffer.from(exports.mongoCACertBase64, 'base64')]; +} +exports.externaldbAddress = 'mongodb://' + exports.mongoUser + ':' + exports.mongoPassword + + exports.mongoDBAddress + '/MDB?connectTimeoutMS=5000&authMechanism=SCRAM-SHA-1&authSource=MDB'; +if (exports.mongoUseSSL) { + exports.externaldbAddress += '&ssl=true'; +} +exports.internaldbAddress = exports.externaldbAddress; // Currently one and the same as there is no internal route. +exports.databaseUpdate = false; // Automatically updates the database to the latest version. +exports.databaseUpdateWrite = true; // Normally true. When false, updates are not actually written ot the database. For testing. +exports.databaseIntegrityCheck = true; // Integrity checking is an option only if databaseUpdate is enabled. +exports.databaseArchiveTransactions = true; // Moves incomplete transactions to the TransactionArchive. +exports.databaseArchiveAccounts = true; // Removes deleted accounts that never made any transactions. + +/** + * Selfie location. + */ +exports.defaultSelfie = 'defaultSelfie'; +exports.defaultCompanyLogo0 = 'defaultCompanyLogo0'; +exports.defaultSelfieData = ''; +exports.defaultCompanyLogo0Data = ''; + +/** + * File system configuration. + */ +exports.temporaryDirectory = path.normalize(global.rootPath + 'temp/'); // Default swap directory. + +/** + * LexisNexis Tracesmart IDU-AML values + * @see https://developer.tracesmart.co.uk/idu-aml + */ +exports.readENVVariable('tracesmartIduAmlUrl', 'tracesmartIduAmlUrl'); +exports.readENVVariable('tracesmartIduAmlUsername', 'tracesmartIduAmlUsername'); +exports.readENVVariable('tracesmartIduAmlPassword', 'tracesmartIduAmlPassword'); + +/** + * Ideal Postcodes API + * @see https://ideal-postcodes.co.uk/ + */ +exports.readENVVariable('idealPostcodesKey', 'idealPostcodesKey'); + +/** + * General Defines. + */ +exports.maxAddresses = 25; +exports.maxRegTokenAttempts = 3; +exports.maxItems = 100; +exports.maxInvoiceNumberAttempts = 3; +exports.callTimeout = 30000; // Number of miliseconds that a server will hold a port open for before it is shut down. + +/** + * Web Console host + * - Used for links in registration emails etc. + */ +exports.readENVVariable('cookieSecret', 'cookieSecret'); +exports.webconsole = { + host: exports.CCWebsiteAddress, + path: '/portal/', + cookieSecret: exports.cookieSecret +}; + +/** + * Rate Limits for express-rate-limit. + * Note that the identifier for the "same" client depends on the type of call. + * It defaults to IP, but some of the APIs can be more specific based on session + * tokens or device ids. + * @see {@link https://github.com/nfriedly/express-rate-limit} + */ +exports.rateLimits = { + /* APIs */ + api: { + windowMs: 15 * 60 * 1000, // 15 min window + max: 900, // Allow 900 requests per 15 min (~10 / second avg) + delayAfter: 0, // Never delay responses, only succeed or fail + delayMs: 0 // Never delay responses, only succeed or fail + }, + + /* Portal static files. */ + portalStatic: { + windowMs: 15 * 60 * 1000, // 15 min window + max: 900, // Allow 900 requests per 15 minute, then fail (429) + delayAfter: 25, // Slow down after the first 25 + delayMs: 10 // Delay by (10ms * (requests-delayAfter)) per request + }, + + /* Anything else - 404 page, selfies and other such things that haven't been removed */ + fallback: { + windowMs: 1 * 60 * 60 * 1000, // 1 hour window + max: 100, // Allow 100 requests per hour, then fail (429) + delayAfter: 1, // Slow down after the first request + delayMs: 10 // Delay by 10ms * (requests-delayAfter) per request + } +}; + +/** + * Secret for creation and validation of JSON Web Tokens for the integrations API + * @see https://github.com/auth0/node-jsonwebtoken + */ +exports.readENVVariable('integrationsTokenSecret', 'integrationsTokenSecret'); + +/** + * The commit hash that the server was built from. + */ +exports.readENVVariable('commitHash', 'commitHash'); +exports.CCServerVersion = packageJson.version + '-' + exports.commitHash; diff --git a/node_server/ComServe/credorax.js b/node_server/ComServe/credorax.js new file mode 100644 index 0000000..12df0e1 --- /dev/null +++ b/node_server/ComServe/credorax.js @@ -0,0 +1,288 @@ +/** + * @fileOverview Node.js Credorax Acquiring Code + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Integrates with Credorax and interprets any results received. + */ + +var config = require(global.configFile); +var querystring = require('querystring'); +var crypto = require('crypto'); +var request = require('request'); +var log = require(global.pathPrefix + 'log.js'); +var sms = require(global.pathPrefix + 'sms.js'); + +/** + * Control defines. + */ +exports.useMCC6012 = 0; // Add [jx] fields. Support seems patchy. +exports.useAVS = 0; // Add [c4-c10] fields. Support seems patchy. +exports.credoraxPostData = 1; // Shows the info sent to Credorax. +exports.primaryFailedComms = 0; // Ticks up every time communications fail with Credorax's primary server. +exports.credoraxSMSAlertSent = 0; // Designed to prevent multiple SMS calls. +exports.credoraxTimeout = 25000; // Apps use 30 seconds as we need a little time to respond to them. + +/** + * List of Appendix D errors so the system interprets a meaningful result. + */ +exports.processingResponseReason = [ + {'00': 'Approved or completed successfully'}, + {'01': 'Refer to card issuer'}, + {'02': 'Refer to card issuer special condition'}, + {'03': 'Invalid merchant'}, + {'04': 'Pick up card'}, + {'05': 'Do not Honour'}, + {'06': 'Invalid Transaction for Terminal'}, + {'07': 'Pick up card special condition'}, + {'08': 'Time-Out'}, + {'09': 'No Original'}, + {'10': 'Approved for partial amount'}, + {'11': 'Partial Approval'}, + {'12': 'Invalid transaction card / issuer / acquirer'}, + {'13': 'Invalid amount'}, + {'14': 'Invalid card number'}, + {'17': 'Invalid Capture date (terminal business date)'}, + {'19': 'System Error; Re-enter transaction'}, + {'20': 'No From Account'}, + {'21': 'No To Account'}, + {'22': 'No Checking Account'}, + {'23': 'No Saving Account'}, + {'24': 'No Credit Account'}, + {'30': 'Format error'}, + {'34': 'Implausible card data'}, + {'39': 'Transaction Not Allowed'}, + {'41': 'Lost Card, Pickup'}, + {'42': 'Special Pickup'}, + {'43': 'Hot Card, Pickup (if possible)'}, + {'44': 'Pickup Card'}, + {'51': 'Not sufficient funds'}, + {'52': 'No checking Account'}, + {'53': 'No savings account'}, + {'54': 'Expired card'}, + {'55': 'Pin incorrect'}, + {'57': 'Transaction not allowed for cardholder'}, + {'58': 'Transaction not allowed for merchant'}, + {'59': 'Suspected Fraud'}, + {'61': 'Exceeds withdrawal amount limit'}, + {'62': 'Restricted card'}, + {'63': 'MAC Key Error'}, + {'65': 'Activity count limit exceeded'}, + {'66': 'Exceeds Acquirer Limit'}, + {'67': 'Retain Card; no reason specified'}, + {'68': 'Response received too late.'}, + {'75': 'Pin tries exceeded'}, + {'76': 'Invalid Account'}, + {'77': 'Issuer Does Not Participate In The Service'}, + {'78': 'Function Not Available'}, + {'79': 'Key Validation Error'}, + {'80': 'Approval for Purchase Amount Only'}, + {'81': 'Unable to Verify PIN'}, + {'82': 'Time out at issuer system'}, + {'83': 'Not declined (Valid for all zero amount transactions)'}, + {'84': 'Invalid Life Cycle of transaction'}, + {'85': 'Not declined'}, + {'86': 'Cannot verify pin'}, + {'87': 'Purchase amount only, no cashback allowed'}, + {'88': 'MAC sync Error'}, + {'89': 'Security Violation'}, + {'91': 'Issuer not available'}, + {'92': 'Unable to route at acquirer Module'}, + {'93': 'Transaction cannot be completed'}, + {'94': 'Duplicate transaction'}, + {'95': 'Contact Acquirer'}, + {'96': 'System malfunction'}, + {'97': 'No Funds Transfer'}, + {'98': 'Duplicate Reversal'}, + {'99': 'Duplicate Transaction'}, + {'N3': 'Cash Service Not Available'}, + {'N4': 'Cash Back Request Exceeds Issuer Limit'}, + {'N7': '(Visa) decline; CVV2 failure.'}, + {'R0': 'Stop Payment Order'}, + {'R1': 'Revocation of Authorisation Order'}, + {'R3': 'Revocation of all Authorisations Order'} +]; + +/** + * Credorax main API function call. + * + * @type {function} CredoraxFunction + * @param {!object} credorax - JSON stucture with filled in data that represents the call. + * @param {!object} M - Merchant ID. + * @param {!object} cipherKey - Key issued to acquiring merchant. + * @param {!function} callback - Call back that returns the result. + */ +exports.CredoraxFunction = function(credorax, M, cipherKey, callback) { + /** + * Local variables. + */ + var cryptoString = M; + + /** + * Parse all keys. + */ + Object.keys(credorax).forEach(function(key) { cryptoString += credorax[key]; }); + + /** + * Create hash. + */ + cryptoString += cipherKey; + var hash = crypto.createHash('MD5').update(cryptoString).digest('hex'); + + /** + * Create query string. + */ + var postData = querystring.stringify({'K': hash, 'M': M}); + postData += '&' + querystring.stringify(credorax); + postData = postData.replace('*', '%2A'); // stringify contains an error that does not process the asterisk correctly. + + /** + * Show the data to be posted. Never post this information on the live server. + */ + if (exports.credoraxPostData && config.isDevEnv) { + log.system( + 'INFO', + ('[OUT] parameters: ' + JSON.stringify(postData)), + 'credorax.CredoraxFunction', + '', + 'System', + '127.0.0.1'); + } + + /** + * Process the data by submitting it to Credorax. + */ + if (postData.length > 0) { + /** + * Set the headers + */ + var headers = { + 'User-Agent': 'Super Agent/0.0.1', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'Content-Length': postData.length + }; + /** + * Configure the request + */ + var options = { + url: config.credoraxCurrentGateway, + method: 'POST', + headers: headers, + form: postData, + timeout: exports.credoraxTimeout + }; + + /** + * Start the request. + */ + request(options, function(error, response, body) { + if (!error && response.statusCode === 200) { + /** + * A response was received. This does not mean the operation was successful. + */ + var credoraxResult = querystring.parse(body); + return callback(null, credoraxResult); + } else if (!error && response.statusCode !== 200) { + /** + * HTTP error. + */ + return callback('Error: HTTP Code: ' + response.statusCode); + } else { + /** + * Return a request error. + */ + return callback('Error: ' + error); + } + }); + } else { + /** + * No data to post. + */ + return callback('No data to process.'); + } +}; + +/** + * Credorax z6 code interpretation. + * + * @type {function} interpretZ6 + * @param {!string} z6 - Error code. + * @return {!string} Interpreted error code or 'Unknown error code' if it is not found. + */ +exports.interpretZ6 = function(z6) { + var reason = 'Unknown error code'; + if (exports.processingResponseReason.hasOwnProperty(z6)) { + reason = exports.processingResponseReason[z6]; + } + return reason; +}; + +/** + * This function deals with Credorax communication failures and gateway switchover if necessary. + * + * @type {function} commsFailure + * @param {!string} source - The function where the error was called from e.g. 'AddCard.process'. + */ +exports.commsFailure = function(source) { + /** + * General error - usually indicates Credorax is down. + * First check the current gateway and switch if necessary. + */ + if (config.credoraxCurrentGateway === config.credoraxPrimaryGateway) { + /** + * Still on primary gateway. + */ + if (exports.primaryFailedComms >= (config.credoraxChangeoverThreshold - 1)) { + /** + * Too many failures. Switching to secondary gateway. + */ + config.credoraxCurrentGateway = config.credoraxSecondaryGateway; + log.system( + 'CRITICAL', + 'Credorax primary gateway down. Moving to secondary gateway.', + source, + '', + 'System', + '127.0.0.1'); + + /** + * Inform admins. + */ + if (exports.credoraxSMSAlertSent === 0) { + /** + * Block multiple SMS messages and send a single one to the admin(s). + * Note that SMS is blocked before we know it has been sent; the callback structure means that this could + * be initialised hundreds of times on a loaded system before the first one returned. + */ + exports.credoraxSMSAlertSent = 1; + sms.sendSMS(null, (sms.adminMobile + ',' + sms.backupMobile), + config.credoraxPrimaryGatewayFailure, function(err, smsBalance) { + if (err) { + log.system( + 'ERROR', + 'Unable to send SMS.', + source, + '', + 'System', + '127.0.0.1'); + return; + } + + /** + * Success. + */ + log.system( + 'INFO', + ('Credorax primary gateway failure. SMS sent to admins (SMS balance now ' + smsBalance + ').'), + source, + '', + 'System', + '127.0.0.1'); + }); + } + } else { + exports.primaryFailedComms += 1; + } + } +}; diff --git a/node_server/ComServe/hJSON.js b/node_server/ComServe/hJSON.js new file mode 100644 index 0000000..5610bf5 --- /dev/null +++ b/node_server/ComServe/hJSON.js @@ -0,0 +1,518 @@ +/* eslint-disable no-var, no-unused-vars, vars-on-top */ +/* eslint-disable spaced-comment, global-require, lines-around-comment, comma-spacing */ +/* eslint-disable no-use-before-define, no-useless-escape, brace-style, padded-blocks */ +/* eslint-disable prefer-arrow-callback, promise/always-return, unicorn/catch-error-name */ +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Comcarde Node.js JSON Handler +// Provides -Bridge- pay functionality. +// Copyright 2014-2015 Comcarde +// Written by Keith Symington +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Includes +var moment = require('moment'); +var querystring = require('querystring'); +var crypto = require('crypto'); +var mongodb = require('mongodb'); +var path = require('path'); + +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var sms = require(global.pathPrefix + 'sms.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var credorax = require(global.pathPrefix + 'credorax.js'); +var config = require(global.configFile); +var SendReport = require(global.pathPrefix + 'hJSON/SendReport'); +var validator = require(global.pathPrefix + '../schemas/validator'); + +// Local variables. +exports.JSONServed = 0; +exports.showPackets = true; // Use to show the incoming packet detail. This is a debug mode. +exports.REST = 1; +exports.form = 2; + +/** + * List of commands that are supported by this function. Commands added to + * this list MUST: + * 1. Have a matching handler in 'ComServe/hJSON/.js'' + * 2. Have a matching schema for the body in 'schemas/.json' + * 3. Have the approprite errorcode added to the list + * + * WARNING: due to the change in direction, ALL commands are now unsupported! + * They have been moved to `UNSUPPORTED_COMMANDS` for easy reverting if needed. + */ +const SUPPORTED_COMMANDS = {}; + +const UNSUPPORTED_COMMANDS = { + /** + * Login and Authorisation Commands + */ + AcceptEULA: {validationErrorCode: 292}, + Authorise2FARequest: {validationErrorCode: 455}, + Get2FARequest: {validationErrorCode: 453}, + KeepAlive: {validationErrorCode: 123}, + LogOut1: {validationErrorCode: 123}, + Login1: {validationErrorCode: 149}, + PINReset: {validationErrorCode: 128}, + RotateHMAC: {validationErrorCode: 444}, + ElevateSession: {validationErrorCode: 558}, + + /** + * Account Commands + */ + AddAddress: {validationErrorCode: 378}, + AddCard: {validationErrorCode: 122}, + ChangePIN: {validationErrorCode: 415}, + ChangePassword: {validationErrorCode: 418}, + DeleteAccount: {validationErrorCode: 151}, + DeleteAddress: {validationErrorCode: 384}, + GetTransactionDetail: {validationErrorCode: 190}, + GetTransactionHistory: {validationErrorCode: 186}, + ListAccounts: {validationErrorCode: 519}, + ListDeletedAccounts: { + validationErrorCode: 519, + commandHandler: 'ListAccounts' + }, + ListAddresses: {validationErrorCode: 376}, + SetAccountAddress: {validationErrorCode: 391}, + SetDefaultAccount: {validationErrorCode: 300}, + + /** + * Image Commands + */ + AddImage: {validationErrorCode: 217}, + GetImage: {validationErrorCode: 220}, + IconCache: {validationErrorCode: -1}, // Has no body, so can't fail validation + ImageCache: {validationErrorCode: 520}, + ReportImage: {validationErrorCode: 223}, + + /** + * Invoice Commands + */ + ConfirmInvoice: {validationErrorCode: 493}, + GetInvoice: {validationErrorCode: 513}, + ListInvoices: {validationErrorCode: 511}, + RejectInvoice: {validationErrorCode: 516}, + + /** + * Merchant Commands + */ + ListItems: {validationErrorCode: 471}, + + /** + * Message Commands + */ + DeleteMessage: {validationErrorCode: 485}, + GetMessage: {validationErrorCode: 479}, + ListMessages: {validationErrorCode: 477}, + MarkMessage: {validationErrorCode: 482}, + + /** + * Payment Commands + */ + CancelPaymentRequest: {validationErrorCode: 161}, + ConfirmTransaction: {validationErrorCode: 181}, + GetTransactionUpdate: {validationErrorCode: 170}, + PayCodeRequest: {validationErrorCode: 150}, + RedeemPayCode: {validationErrorCode: 174}, + RefundTransaction: {validationErrorCode: 227}, + + /** + * Registration Commands + */ + AddDevice: {validationErrorCode: 330}, + DeleteDevice: {validationErrorCode: 363}, + GetClientDetails: {validationErrorCode: 424}, + ListDevices: {validationErrorCode: 361}, + Register1: {validationErrorCode: 2}, + Register2: {validationErrorCode: 124}, + Register3: {validationErrorCode: 125}, + Register4: {validationErrorCode: 126}, + Register6: {validationErrorCode: 140}, + Register7: { + validationErrorCode: 141, + paramsValidator: 'Register7.params' + }, + Register8: {validationErrorCode: 142}, + ResumeDevice: {validationErrorCode: 432}, + SetClientDetails: {validationErrorCode: 422}, + SetDeviceName: {validationErrorCode: 438}, + SuspendDevice: {validationErrorCode: 427}, + + /** + * Utils functions + */ + PostCodeLookup: {validationErrorCode: 530} +}; + +/** + * Define where the schemas should be loaded from + */ +const SCHEMA_DIR = path.join(global.pathPrefix, '..', 'schemas'); + +/** + * Define where the command handlers should be loaded from + */ +const COMMAND_HANDLER_DIR = path.join(global.pathPrefix, 'hJSON'); + +/** + * Stores the command handlers that are loaded based on the SUPPORTED_COMMANDS + */ +var commandHandlers = {}; + +/** + * Loads the command handlers base on the list of supported commands + */ +function loadCommandHandlers() { + /** + * Pull in all the command handlers + */ + var commands = Object.keys(SUPPORTED_COMMANDS); + var paramsSchemas = ['defaultCommandOnly.params']; // Default unless otherwise specified + + for (var i = 0; i < commands.length; ++i) { + /** + * Default command name is the same as the key + */ + var command = commands[i]; + var commandHandler = command; + + /** + * Some commands need a custom command handler name. + * e.g. `ListDeletedAccounts` uses `ListAccounts` + */ + var commandInfo = SUPPORTED_COMMANDS[command]; + if (commandInfo && commandInfo.commandHandler) { + commandHandler = commandInfo.commandHandler; + } + + /** + * `require` the command handler + */ + commandHandlers[command] = require( + path.join(COMMAND_HANDLER_DIR, commandHandler) + ); + + /** + * Some commands have custom parameters validators. If so, add them + * to the additional schemas list. + */ + if (commandInfo && commandInfo.paramsValidator) { + paramsSchemas.push(commandInfo.paramsValidator); + } + } + + /** + * Initialise the validator with the same list of supported commands + */ + var schemas = commands.concat(paramsSchemas); + validator.initialise(schemas, config.isDevEnv, SCHEMA_DIR); +} +/** + * Call loadCommandHandlers immediately to register all the handlers we have + */ +loadCommandHandlers(); + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Reads the JSON data out of the packet. +// req and res are the request and response packets. +// remoteAddress: is the source address of the incoming link. +// protocolPort is 'HTTPS:443' for example. This is a text string only that is put to the logs. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +exports.handleJSONRequest = function(req, res, remoteAddress, protocolPort, parameters, type) { + // Local objects. + var serverData = ''; + var receivedObject = {}; + + // Reset number of JSON requests served. + exports.JSONServed++; + + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Receipt of 'data' function. + //////////////////////////////////////////////////////////////////////////////////////////////////// + var dataReceived = function(chunk) { + // Check that the maximum packet size is not above the current limit. + if (serverData.length > utils.maxPacketSize) { + // Too much data. Shut down receive methods. + req.removeListener('data', dataReceived); + req.removeListener('end', requestEnd); + // Return an error code. + res.writeHead(413, {'Content-Type': 'application/json'}); + res.end( + '{\"code\":\"280\",' + + '\"info\":\"Packet too large.\"}'); + log.system( + 'ATTACK', + 'Packet too large.', + 'hJSON.handleJSONRequest', + '280', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } else { // Overflow limit not reached. Add the data. + serverData += chunk; + } + }; + + //////////////////////////////////////////////////////////////////////////////////////////////////// + // 'End' of data stream. + //////////////////////////////////////////////////////////////////////////////////////////////////// + //jshint -W074 + var requestEnd = function() { + // Protect against unhandled exceptions. + try { + // Try to parse the data to see if it is JSON. + try { + // Parse the data received. Some commands are pure rest so skip null data. + if (serverData !== '') { + if (type === exports.REST) { + receivedObject = JSON.parse(serverData); + } else if (type === exports.form) { + receivedObject = querystring.parse(serverData); + } + } + } + catch (err) { + // Unable to process querystring or body JSON. + res.writeHead(200, {'Content-Type': 'application/json'}); + if (type === exports.REST) { + res.end( + '{\"code\":\"324\",' + + '\"info\":\"Invalid JSON in packet.\"}'); + log.system( + 'WARNING', + ('Invalid JSON in packet. ' + err.name + ' (' + err.message + ') Message not logged for security reasons.'), + 'hJSON.handleJSONRequest', + '324', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } else if (type === exports.form) { + res.end( + '{\"code\":\"325\",' + + '\"info\":\"Invalid querystring.\"}'); + log.system( + 'WARNING', + ('Invalid querystring. ' + err.name + ' (' + err.message + ') Message not logged for security reasons.'), + 'hJSON.handleJSONRequest', + '325', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } + // Return after error. + return; + } + + // Detailed logging of input/output for debug purposes. Never show in the live environment. + if (exports.showPackets && config.isDevEnv) { + if (type === exports.REST) { + log.system( + 'INFO', + ('[IN] parameters: ' + JSON.stringify(parameters) + ' [REST IN] parsed data: ' + + JSON.stringify(receivedObject)), + 'hJSON.showPackets', + '', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } else if (type === exports.form) { + log.system( + 'INFO', + ('[IN] parameters: ' + JSON.stringify(parameters) + ' [FORM IN] parsed data: ' + + JSON.stringify(receivedObject)), + 'hJSON.showPackets', + '', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } + } + + /** + * Create hmac object. + */ + var hmacData = {}; + hmacData.address = 'https://' + req.headers.host + req.url; + hmacData.method = req.method; + hmacData.body = serverData; + if ('bridge-timestamp' in req.headers) { + hmacData.timestamp = req.headers['bridge-timestamp']; + } + if ('bridge-hmac' in req.headers) { + hmacData.hmac = req.headers['bridge-hmac']; + } + + /** + * Check for unknown packets. Note there are a couple of exceptions where commands + * can be generated by non-apps. + */ + if (!(('user-agent' in req.headers) && ((req.headers['user-agent'].substr(0,6)) === 'Bridge'))) { + if ((parameters.Command !== 'Register7') && + (parameters.Command !== 'SendReport')) { + res.status(403).json({ + code: '464', + info: 'Forbidden.' + }); + log.system( + 'WARNING', + 'Request forbidden: user-agent is not Bridge.', + 'hJSON.handleJSONRequest', + '464', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + return; + } + } + + /** + * Build function info. + */ + var functionInfo = {}; + functionInfo.name = parameters.Command + '.process'; + functionInfo.remote = remoteAddress; + functionInfo.port = protocolPort; + + /** + * Check if this is a command that is handled from the dynamically + * loaded and validated commands. + */ + if (commandHandlers.hasOwnProperty(parameters.Command)) { + /** + * It is a dynamically created function so handle it here + */ + doCommand(res, functionInfo, parameters, receivedObject, hmacData); + } else { + /** + * All of the hard-coded JSON commands are examined here. The most common should be put at the top. + * Once all commands have been converted over to using JSON Schema validation we can + * remove this case. + */ + switch (parameters.Command) { + case 'SendReport': + SendReport.process(res, functionInfo, parameters); + break; + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Error condition - unknown commands. + //////////////////////////////////////////////////////////////////////////////////////////////////// + default: + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end( + '{\"code\":\"0\",' + + '\"info\":\"Unknown Command.\"}'); + log.system( + 'WARNING', + ('Unknown \"Command:\" in url (' + parameters.Command + ')'), + 'hJSON.handleJSONRequest', + '0', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } + } + } + catch (err) { + // Processing error. Now actual error returned in message + var responseObj = { + code: '4', + info: 'Unhandled Exception - ' + err.message + }; + res.status(200).json(responseObj); + log.system( + 'CRITICAL', + ('Unhandled Exception - ' + err.name + ' (' + err.message + ')'), + 'hJSON.handleJSONRequest', + '4', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } + }; + + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Bind events to functions. + //////////////////////////////////////////////////////////////////////////////////////////////////// + req.on('data', dataReceived); // Data indicates that information is still arriving. + req.on('end', requestEnd); // End indicates that everything has been read. +}; + +/** + * Runs the command specified by `parameters.Command`. + * It first validates the body using JSON Schema, then calls the command handler + * + * @param {!Object} res - Response object for returning information. + * @param {!Object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?Object} parameters - Input parameters posted on link. + * @param {?Object} receivedObject - Input parameters in message body. + * @param {!Object} hmacData - hmac information {!address, !method, !body, ?timestamp, ?hmac} + */ +function doCommand(res, functionInfo, parameters, receivedObject, hmacData) { + var commandName = parameters.Command; + var commandInfo = SUPPORTED_COMMANDS[commandName]; + var paramsSchema = commandInfo.paramsValidator || 'defaultCommandOnly.params'; + + var bodyValidatorP = validator.validate(commandName, receivedObject); + var paramsValidatorP = validator.validate(paramsSchema, parameters); + + Promise.all([bodyValidatorP, paramsValidatorP]) + .then(function onValidationSucceeded() { + /** + * Validated, so run the command + */ + try { + commandHandlers[commandName].process( + res, + functionInfo, + parameters, + receivedObject, + hmacData + ); + } catch (err) { + /* Processing error. Return it to the caller */ + var responseObj = { + code: '4', + info: 'Unhandled exception - ' + err.message + }; + auth.respond( + res, + 200, + null, // Don't know what device was used + null, // Don't pass in HMAC data as we don't have a device. + functionInfo, + responseObj, + 'CRITICAL' + ); + } + }) + .catch(function onValidationFailed(err) { + var hasErrorDetail = Array.isArray(err.errors) && err.errors.length > 0; + /** + * Failed validation, so return an error + */ + var responseObj = { + code: commandInfo.validationErrorCode ? + commandInfo.validationErrorCode.toString() : + '-1', + info: 'Invalid body in request' + }; + + /* Add a little more detail if we have it */ + if (hasErrorDetail) { + responseObj.info = + 'Invalid body' + + err.errors[0].dataPath; + } + + /* If we are in dev, also return the detailed error info from the validator */ + if (config.isDevEnv && hasErrorDetail) { + responseObj.info += ': ' + err.errors[0].message; + responseObj.devOnlyErrorDetail = err; + } + + auth.respond( + res, + 200, + null, // Don't know what device was used + null, // Don't pass in HMAC data as we don't have a device. + functionInfo, + responseObj, + 'WARNING' + ); + + }); +} diff --git a/node_server/ComServe/hJSON/AcceptEULA.js b/node_server/ComServe/hJSON/AcceptEULA.js new file mode 100644 index 0000000..586e6f6 --- /dev/null +++ b/node_server/ComServe/hJSON/AcceptEULA.js @@ -0,0 +1,72 @@ +/** + * @fileOverview Node.js AcceptEULA Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Confirms that the user has accepted the EULA. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/accepteula/} + */ + +/** + * Includes + */ +var auth = require(global.pathPrefix + 'auth.js'); +var log = require(global.pathPrefix + 'log.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * New version accepted. + */ + var newLastUpdate = new Date(); + mainDB.updateObject(mainDB.collectionClient, {ClientID: existingClient.ClientID}, { + $set: { + EULAVersionAccepted: config.EULAVersion, + LastUpdate: newLastUpdate + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '290', + info: 'Database offline.' + }); + return; + } + + /** + * Updated. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10044', + info: 'EULA acceptance confirmed.' + }, + 'INFO', + ('V' + config.EULAVersion + ' EULA acceptance confirmed.')); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/AddAddress.js b/node_server/ComServe/hJSON/AddAddress.js new file mode 100644 index 0000000..56415ca --- /dev/null +++ b/node_server/ComServe/hJSON/AddAddress.js @@ -0,0 +1,180 @@ +/** + * @fileOverview Node.js Add Address Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Adds an address which will be associated with the current client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/addaddress/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Search for the requested addresses. + * Cyclomatic complexity warning disabled. + */ + //jshint -W074 + mainDB.collectionAddresses.find({ClientID: existingClient.ClientID}, + { + _id: 1, + AddressDescription: 1 + } + ).toArray(function(err, addresses) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '379', + info: 'Database offline.' + }); + return; + } + + /** + * Check for maximum number of entries. GTE used to trap problem cases or changes + * in config.maxAddresses. + */ + if (addresses.length >= config.maxAddresses) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '380', + info: 'Maximum number of addresses reached.' + }, + 'WARNING'); + return; + } + + /** + * Check that the description does not already exist. + */ + var counter; + for (counter = 0; counter < addresses.length; counter++) { + if (addresses[counter].AddressDescription === receivedObject.AddressDescription) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '381', + info: 'AddressDescription already exists.' + }, + 'WARNING'); + return; + } + } + + /** + * Create and populate the new address. + */ + var timestamp = new Date(); + var newAddress = mainDB.blankAddress(); + newAddress.ClientID = existingClient.ClientID; + newAddress.AddressDescription = receivedObject.AddressDescription; + if (receivedObject.BuildingNameFlat) { + newAddress.BuildingNameFlat = receivedObject.BuildingNameFlat; + } + newAddress.Address1 = receivedObject.Address1; + if (receivedObject.Address2) { + newAddress.Address2 = receivedObject.Address2; + } + newAddress.Town = receivedObject.Town; + if (receivedObject.County) { + newAddress.County = receivedObject.County; + } + newAddress.PostCode = receivedObject.PostCode; + newAddress.Country = receivedObject.Country; + if (receivedObject.PhoneNumber) { + newAddress.PhoneNumber = receivedObject.PhoneNumber; + } + newAddress.DateAdded = timestamp; + newAddress.LastUpdate = timestamp; + + /** + * Add the object to the addresses database. + */ + mainDB.addObject(mainDB.collectionAddresses, newAddress, undefined, false, function(err, objectAdded) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '382', + info: 'Database offline.' + }); + return; + } + + /** + * Address successfully added. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10053', + info: 'Address added.', + AddressID: objectAdded[0]._id + }, + 'INFO'); + + /** + * Check KYC flag and update if necessary. + * note that this is a valid bitwise comparison and manipulation. + */ + //jshint -W016 + if (!(existingClient.ClientStatus & utils.ClientAddressMask)) { + var newClientStatus = existingClient.ClientStatus | utils.ClientAddressMask; + //jshint +W016 + mainDB.updateObject(mainDB.collectionClient, {ClientID: existingDevice.ClientID}, + { + $set: { + ClientStatus: newClientStatus, + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + log.system( + 'ERROR', + 'Could not set address mask flag set (KYC2) as database was offline.', + 'AddAddress.process', + '', + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + return; + } + + /** + * System note that the KYC flag has been set. + */ + log.system( + 'INFO', + 'Client address mask flag set (KYC2).', + 'AddAddress.process', + '', + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + }); + } + }); + }); + //jshint +W074 + }); +}; diff --git a/node_server/ComServe/hJSON/AddCard.js b/node_server/ComServe/hJSON/AddCard.js new file mode 100644 index 0000000..61e7bb9 --- /dev/null +++ b/node_server/ComServe/hJSON/AddCard.js @@ -0,0 +1,336 @@ +/** + * @fileOverview Node.js AddCard handling code for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Adds a card to the account table associated to the supplied user. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/addcard/} + */ +'use strict'; + +/** + * Includes needed for this module. + */ +const _ = require('lodash'); +var moment = require('moment'); +var mongodb = require('mongodb'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); +var worldpay = require(global.pathPrefix + 'worldpay.js'); +var sms = require(global.pathPrefix + 'sms.js'); +var anon = require(global.pathPrefix + '../utils/anon.js'); +const acquirers = require(global.pathPrefix + '../utils/acquirers/acquirer.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Ensure that the client details are set. + */ + if (!(utils.bitsAllSet(existingClient.ClientStatus, utils.ClientDetailsMask))) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '521', + info: 'No client details set.' + }, + 'INFO'); + return; + } + + /** + * Retrieve the address from the database. + */ + mainDB.findOneObject(mainDB.collectionAddresses, + { + _id: mongodb.ObjectID(receivedObject.BillingAddress), + ClientID: existingClient.ClientID + }, + undefined, + false, + function(err, existingAddress) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '522', + info: 'Database offline.' + }); + return; + } + + /** + * Check if any addresses match the search query. + */ + if (!existingAddress) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '523', + info: 'Invalid billing address.' + }, + 'WARNING'); + return; + } + + /** + * Create a new Account and fill in as a card. + */ + var newCard = mainDB.blankAccount(); + newCard.ClientID = existingClient.ClientID; + newCard.ClientAccountName = receivedObject.ClientAccountName; + newCard.UserImage = receivedObject.UserImage; + newCard.NameOnAccount = receivedObject.NameOnAccount; + + /** + * Encrypt and store the card details. + */ + var temp; + /** + * CardPAN + */ + temp = utils.encryptDataV3(receivedObject.CardPAN, receivedObject.ClientKey, existingClient._id.toString()); + if (typeof temp !== 'string') { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '524', + info: 'Error when encrypting CardPAN.' + }, + 'WARNING', + ('Encryption V3 error: CardPAN, Code: ' + temp.code.toString() + ' (' + temp.message + ').')); + return; + } + newCard.CardPAN = anon.anonymiseCardPAN(receivedObject.CardPAN); + newCard.CardPANEncrypted = temp; + /** + * CardExpiry + */ + temp = utils.encryptDataV3(receivedObject.CardExpiry, receivedObject.ClientKey, existingClient._id.toString()); + if (typeof temp !== 'string') { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '524', + info: 'Error when encrypting CardExpiry.' + }, + 'WARNING', + ('Encryption V3 error: CardExpiry, Code: ' + temp.code.toString() + ' (' + temp.message + ').')); + return; + } + newCard.CardExpiryEncrypted = temp; + /** + * CardValidFrom + */ + if (receivedObject.CardValidFrom) { + temp = utils.encryptDataV3(receivedObject.CardValidFrom, receivedObject.ClientKey, existingClient._id.toString()); + if (typeof temp !== 'string') { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '524', + info: 'Error when encrypting CardValidFrom.' + }, + 'WARNING', + ('Encryption V3 error: CardValidFrom, Code: ' + temp.code.toString() + ' (' + temp.message + ').')); + return; + } + newCard.CardValidFromEncrypted = temp; + } + /** + * IssueNumber + */ + if (receivedObject.IssueNumber) { + temp = utils.encryptDataV3(receivedObject.IssueNumber, receivedObject.ClientKey, existingClient._id.toString()); + if (typeof temp !== 'string') { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '524', + info: 'Error when encrypting IssueNumber.' + }, + 'WARNING', + ('Encryption V3 error: IssueNumber, Code: ' + temp.code.toString() + ' (' + temp.message + ').')); + return; + } + newCard.IssueNumberEncrypted = temp; + } + newCard.AccountType = 'Credit/Debit Payment Card'; + newCard.VendorAccountName = 'Credit/Debit Card'; + newCard.BillingAddress = receivedObject.BillingAddress; + var cardDetails = utils.identifyCard(receivedObject.CardPAN); + newCard.VendorID = cardDetails.type; + newCard.IconLocation = cardDetails.icon; + newCard.ReceivingAccount = 0; + newCard.PaymentsAccount = 1; + newCard.BalanceAvailable = 0; + newCard.Balance = null; + newCard.LastUpdate = new Date(); + + /** + * Tokenise the card to check it is valid. The default method can be set in config. + * If the default method is 'None' then the details will simply be stored; the assumption is that no checking need + * be done at this stage. + * Entry of a demo card will show the verification provider as 'Demo'. + */ + var provider = config.verificationProvider; + if (receivedObject.CardPAN === config.demoCardPAN) { + provider = 'Demo'; + } + newCard.AcquirerName = provider; + + /** + * Use Worldpay to tokenise the card. + */ + if (provider === 'Worldpay') { + /** + * Build the card details for the acquirer to tokenise + * This is just an extract from the receivedObject + */ + const tokeniseDetails = _.pick( + receivedObject, + [ + 'NameOnAccount', + 'CardPAN', + 'CVV', + 'CardExpiry', + 'CardValidFrom', + 'IssueNumber' + ] + ); + + /** + * make the request to tokenise + */ + acquirers.tokeniseCard( + provider, + tokeniseDetails, + receivedObject.ClientKey, + existingClient._id.toString() + ).then((cardDetails) => { + /** + * Update the account object with the new details + */ + const updatedCard = _.assign( + {}, + newCard, + cardDetails + ); + + /** + * Add the card to the Account collection. + */ + mainDB.addObject(mainDB.collectionAccount, updatedCard, undefined, false, function(err, objectAdded) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '529', + info: 'Database offline.' + }); + return; + } + + /** + * Respond to client. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10012', + info: 'Card added (tokenised with Worldpay).', + AccountID: objectAdded[0]._id + }, + 'INFO', + ('New card account ID ' + objectAdded[0]._id + ' added (Worldpay ID: ' + ').')); + }); + }).catch((err) => { + /** + * Report an appropriate error to the acquirer not tokenising properly + */ + if (err.name === acquirers.ERRORS.ACQUIRER_DOWN) { + worldpay.commsFailure('AddCard.process'); + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '527', + info: 'Cannot connect to verifying bank (Worldpay); card addition failed.' + }, + 'ERROR'); + return; + } else if (err.name === acquirers.ERRORS.TOKEN_ENCRYPTION_FAILED) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '524', + info: 'Error when encrypting Token.' + }, + 'WARNING', + ('Encryption V3 error when encrypting token. ' + err.info)); + return; + } else { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '528', + info: 'Card tokenisation failed.' + }, + 'WARNING', + ('Cannot tokenise card. ' + err.name + ':' + err.message)); + return; + } + }); + } + /** + * Do not tokenise the card - simply store the details. + * This can either be where the + */ + else if ((provider === 'None') || (provider === 'Demo')) { + /** + * Data is simply stored; either a demo card or verification disabled. + */ + if (provider === 'Demo') { + newCard.ClientAccountName += ' (Demo)'; + } + + /** + * Add the card to the Account collection. + */ + mainDB.addObject(mainDB.collectionAccount, newCard, undefined, false, function(err, objectAdded) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '526', + info: 'Database offline.' + }); + return; + } + + /** + * Respond to client. + */ + var variedInfo = ''; + if (provider === 'Demo') { + variedInfo = '(demo account)'; + } else { + variedInfo = '(not tokenised)'; + } + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10012', + info: 'Card added ' + variedInfo + '.', + AccountID: objectAdded[0]._id + }, + 'INFO', + ('New card account ID ' + objectAdded[0]._id + ' added ' + variedInfo + '.')); + }); + } + /** + * Default case - system disabled. + */ + else { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '525', + info: 'Card addition disabled.' + }, + 'WARNING'); + } + }); + }); +}; diff --git a/node_server/ComServe/hJSON/AddImage.js b/node_server/ComServe/hJSON/AddImage.js new file mode 100644 index 0000000..9adea86 --- /dev/null +++ b/node_server/ComServe/hJSON/AddImage.js @@ -0,0 +1,341 @@ +/** + * @fileOverview Node.js AddImage Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Adds an image to the image database. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/addimage/} + */ + +// Legacy code line length disable. +// jscs:disable maximumLineLength +//jshint -W101 + +/** + * Includes + */ +var moment = require('moment'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var credorax = require(global.pathPrefix + 'credorax.js'); +var mongodb = require('mongodb'); +var gm = require('gm'); +var fs = require('fs'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Create a buffer and write the image to a temporary disk file. + */ + var buf = new Buffer(receivedObject.ImageFile, 'base64'); + var tempFileName = moment().format('YYYYMMDDTHHmmssSSS') + utils.randomCode(utils.fullAlphaNumeric, 14); + fs.writeFile((config.temporaryDirectory + tempFileName), buf, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '284', + info: 'Internal server error.' + }, + 'CRITICAL', + ('File creation error: ' + err.message)); + return; + } + + /** + * Check this is a valid image file. + */ + gm((config.temporaryDirectory + tempFileName)).format(function(err, fileFormat) { + if ((err) || ((fileFormat !== 'PNG') && (fileFormat !== 'JPEG')) || + ((fileFormat === 'PNG') && (receivedObject.FileType !== 'PNG')) || + ((fileFormat === 'JPEG') && (receivedObject.FileType !== 'JPG'))) { + /** + * Add more detail. + */ + var furtherInfo = ''; + if (err) { + furtherInfo = err; + } else if ((fileFormat !== 'PNG') && (fileFormat !== 'JPEG')) { + furtherInfo = 'Not a PNG or JPEG file'; + } else { + furtherInfo = 'File format is not that specified in FileType'; + } + + /** + * Let the user know the image file is invalid. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '285', + info: 'Invalid image file.' + }, + 'WARNING', + ('Invalid image file (' + furtherInfo + ').')); + return; + } + + /** + * Check that the file size is reasonable. + */ + gm((config.temporaryDirectory + tempFileName)).size(function(err, fileSize) { + if ((err) || (fileSize.width > utils.ImageWidthMax) || + (fileSize.height > utils.ImageHeightMax) || + (fileSize.width !== fileSize.height)) { + /** + * Add more detail. + */ + var furtherInfo = ''; + if (err) { + furtherInfo = err; + } else if (fileSize.width !== fileSize.height) { + furtherInfo = 'The image is not square'; + } else if (fileSize.width > utils.ImageWidthMax) { + furtherInfo = 'File is over ' + utils.ImageWidthMax + ' pixels wide'; + } else { + furtherInfo = 'File is over ' + utils.ImageHeightMax + ' pixels high'; + } + + /** + * Let the user know the image file is invalid. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '286', + info: 'Image dimensions invalid.' + }, + 'WARNING', + ('Image dimensions invalid (' + furtherInfo + ').')); + return; + } + + /** + * Image is valid. Push image to Bluemix. + */ + config.bluemixContainer.createObject(tempFileName, receivedObject.ImageFile) + .then(function() { + /** + * Create new database entry. + */ + var timestamp = new Date(); + var newImage = {}; + newImage.ClientID = existingClient.ClientID; + newImage.ImageFile = tempFileName; + newImage.ImageType = receivedObject.ImageType; + newImage.FileType = receivedObject.FileType; + newImage.ImageReported = 0; + newImage.LastUpdate = timestamp; + + /** + * Write the image and get the object ID back. + */ + mainDB.addObject(mainDB.collectionImages, newImage, undefined, false, function(err, imageAdded) { + if (err) { // Unable to store info. + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '218', + info: 'Database offline.' + }); + return; + } + + /** + * Image successfully added. Update the client record. + */ + var ImageID = mongodb.ObjectID(imageAdded[0]._id).toString(); + var Selfie = existingClient.Selfie; + if (receivedObject.ImageType === 'Selfie') { + Selfie = ImageID; + } + var CompanyLogo = existingClient.Merchant[0].CompanyLogo; + if (receivedObject.ImageType === 'CompanyLogo0') { + CompanyLogo = ImageID; + } + var newLastVersion = existingClient.LastVersion + 1; + + /** + * Update the client record. + */ + mainDB.updateObject(mainDB.collectionClient, + {ClientID: existingClient.ClientID}, { + $set: { + Selfie: Selfie, + 'Merchant.0.CompanyLogo': CompanyLogo, + LastUpdate: timestamp, + LastVersion: newLastVersion + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '219', + info: 'Database offline.' + }); + return; + } + + /** + * Delete the temporary file. + */ + fs.unlink((config.temporaryDirectory + tempFileName), function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '287', + info: 'Internal server error.' + }, + 'CRITICAL', + ('File deletion error: ' + err.message)); + return; + } + + /** + * Return code to user. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10031', + info: 'Image added.', + ImageRef: ImageID + }, + 'INFO', + ('New ' + receivedObject.FileType + ' image added (' + fileSize.width + + 'x' + fileSize.height + ', ID ' + ImageID + ', S3 ' + tempFileName + ').')); + + /** + * Launch async cleanup task to remove old image if it was never used. + * This can be slow so is launched independently of image addition. + */ + var searchFor = ''; + if (receivedObject.ImageType === 'Selfie') { + searchFor = existingClient.Selfie; + } else { + searchFor = existingClient.Merchant[0].CompanyLogo; + } + + /** + * Protect the default images. + */ + if ((searchFor === config.defaultSelfie) || (searchFor === config.defaultCompanyLogo0)) { + log.system( + 'INFO', + 'Default image detected (not deleted).', + 'AddImage.process', + '', + 'System', + '127.0.0.1'); + return; + } + + mainDB.findOneObject(mainDB.collectionTransactionHistory, {OtherImage: searchFor}, + undefined, + false, + function(err, oldTransaction) { + if (!err) { + /** + * Database module writes its own errors. + */ + if (oldTransaction) { + log.system( + 'INFO', + ('Old ' + receivedObject.FileType + ' image in use (ID ' + searchFor + + '). Not deleted.'), + 'AddImage.process', + '', + 'System', + '127.0.0.1'); + return; + } + + /** + * Image not used in a transaction. + * Pull the database reference. + */ + mainDB.findOneObject(mainDB.collectionImages, {_id: mongodb.ObjectID(searchFor)}, + undefined, + false, + function(err, oldImage) { + if (!err) { + // Database module writes its own errors. + if (!oldImage) { + log.system( + 'WARNING', + ('Cannot find Image in database (ID ' + searchFor + ').'), + 'AddImage.process', + '', + 'System', + '127.0.0.1'); + return; + } + + /** + * Image is present. Remove from IBM OS. + */ + config.bluemixContainer.deleteObject(oldImage.ImageFile) + .then(function() { + /** + * Remove the Images database entry. + */ + mainDB.removeObject(mainDB.collectionImages, {_id: mongodb.ObjectID(searchFor)}, undefined, false, function(err) { + if (!err) { + log.system( + 'INFO', + ('Unused ' + receivedObject.FileType + ' deleted from IBM OS (ID ' + searchFor + + ', OS ' + oldImage.ImageFile + ').'), + 'AddImage.process', + '', + 'System', + '127.0.0.1'); + } + }); + }) + .catch(function(err) { + log.system( + 'WARNING', + (receivedObject.FileType + ' image not deleted from IBM OS (ID ' + searchFor + ', OS ' + + oldImage.ImageFile + '). ' + err.message), + 'AddImage.process', + '', + 'System', + '127.0.0.1'); + }); + } + }); + } + }); + }); + }); + }); + }) + .catch(function(err) { + /** + * Error putting the file on S3. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '463', + info: 'Could not store image.' + }, + 'WARNING', + ('Error uploading image to Bluemix Object Storage (' + err.message + ').')); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Authorise2FARequest.js b/node_server/ComServe/hJSON/Authorise2FARequest.js new file mode 100644 index 0000000..fb221df --- /dev/null +++ b/node_server/ComServe/hJSON/Authorise2FARequest.js @@ -0,0 +1,130 @@ +/** + * @fileOverview Node.js handler to authorise an outstanding 2FA request for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * Authorises the identified 2FA request from a web console login request + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/authorise2farequest/} + */ + +/** + * Includes + */ +var Q = require('q'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * This authorises an outstanding 2-factor authentication request arising from + * an attempt to log into the web console. The RequestID for this request + * comes from a previous request to Get2FARequest. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Authorise the identified request, if it can be found. + * + * Build the query. The limits are: + * - TargetAccount must match the id of the current client + * - RequestID must match the RequestID + * - Request must not be expired + * - Request must not be already authorised (i.e. no authorised date) + */ + var timestamp = new Date(); + var query = { + TargetAccount: existingClient.ClientID, + RequestID: receivedObject.RequestID, + RequestExpiry: {$gt: timestamp}, + AuthorisedDate: {$type: 10} // Must exist and be exactly `null` (BSON Type 10) + }; + + /** + * Define the update + */ + var newExpiry = new Date(timestamp); + newExpiry.setSeconds( + timestamp.getSeconds() + utils.twoFactorRequestExpiry + ); + var update = { + $set: { + AuthorisedDate: timestamp, + AuthorisingDeviceID: existingDevice.DeviceUuid, + RequestExpiry: newExpiry, + LastUpdate: timestamp + } + }; + + /** + * Build the options. + */ + var options = { + upsert: false, + multi: false, + comment: 'authorise2FARequest' // For profiler logs use + }; + + /** + * Request the object + */ + Q.nfcall( + mainDB.updateObject, + mainDB.collectionTwoFARequests, + query, + update, + options, + false + ).then(function(result) { + /** + * Successful query (though it may not have found anything to update) + */ + if (result.result.n === 1) { + /** + * A document was updated, so this is total success + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10067', + info: 'Authorisation successful.' + }, + 'INFO', + ('Authorise2FARequest successful: ' + receivedObject.RequestID)); + } else { + /** + * The request ran, but didn't find any documents + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '457', + info: 'Invalid or expired request ID.' + }, + 'WARNING', + ('Invalid or expired request ID: ' + receivedObject.RequestID)); + } + }).catch(function() { + /** + * Query failed, most likely from the database + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '456', + info: 'Database offline.' + }); + }); + }); // End auth.validSession callback +}; diff --git a/node_server/ComServe/hJSON/CancelPaymentRequest.js b/node_server/ComServe/hJSON/CancelPaymentRequest.js new file mode 100644 index 0000000..4248e26 --- /dev/null +++ b/node_server/ComServe/hJSON/CancelPaymentRequest.js @@ -0,0 +1,198 @@ +/** + * @fileOverview Node.js CancelPaymentRequest Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Cancels a payment request, sets TransactionStatus appropriately. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/cancelpaymentrequest/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice) { + if (err) { + return; + } + + /** + * Either the payer or payee can cancel the transaction. + */ + //jshint -W074 + mainDB.findOneObject(mainDB.collectionTransaction, {_id: mongodb.ObjectID(receivedObject.TransactionID)}, + undefined, + false, + function(err, existingTransaction) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '162', + info: 'Database offline.' + }); + return; + } + + /** + * Check to see if the transaction exists. + */ + if (existingTransaction === null) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '163', + info: 'Cannot find transaction.' + }, + 'WARNING'); + return; + } + + /** + * Ensure that it's either the customer or merchant requesting cancellation. + * If this is true it indicated a session timeout. + */ + if (!(((receivedObject.DeviceToken === existingTransaction.CustomerDeviceToken) && + (receivedObject.SessionToken === existingTransaction.CustomerSessionToken)) || + ((receivedObject.DeviceToken === existingTransaction.MerchantDeviceToken) && + (receivedObject.SessionToken === existingTransaction.MerchantSessionToken)))) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '164', + info: 'Session timed out.' + }, + 'WARNING'); + return; + } + + /** + * User is allowed to cancel the transaction. Default is to update the transaction. + */ + var updateTransaction = false; + var newTransactionStatus; + var newStatusInfo; + switch (existingTransaction.TransactionStatus) { + case 0: + /** + * Just issued. + */ + newTransactionStatus = 10; + newStatusInfo = 'Paycode cancelled before use by customer.'; + updateTransaction = true; + break; + case 1: + /** + * Code claimed. + */ + newTransactionStatus = 11; + if (receivedObject.DeviceToken === existingTransaction.CustomerDeviceToken) { + newStatusInfo = 'Payment process cancelled by customer.'; + } else { + newStatusInfo = 'Payment process cancelled by merchant.'; + } + updateTransaction = true; + break; + case 2: + /** + * Payment underway. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '165', + info: 'Transfer underway.' + }, + 'WARNING'); + break; + case 3: + /** + * Payment complete. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '166', + info: 'Payment complete.' + }, + 'WARNING'); + break; + default: + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '167', + info: 'Payment already cancelled.' + }, + 'WARNING'); + break; + } + + /** + * Update the transaction if needed. + */ + if (updateTransaction) { + var newLastUpdate = new Date(); + var newLastVersion = existingTransaction.LastVersion + 1; + mainDB.updateObject(mainDB.collectionTransaction, + {_id: mongodb.ObjectID(receivedObject.TransactionID)}, { + $set: { + TransactionStatus: newTransactionStatus, + StatusInfo: newStatusInfo, + LastUpdate: newLastUpdate, + LastVersion: newLastVersion + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '168', + info: 'Database offline.' + }); + return; + } + + /** + * Delete PayCode so it cannot be matched again. + */ + mainDB.removeObject(mainDB.collectionPayCode, + {_id: existingTransaction.PayCodeID}, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '169', + info: 'Database offline.' + }); + return; + } + + /** + * Payment successfully cancelled. + */ + let cancelledBy = ''; + if (receivedObject.DeviceToken === existingTransaction.CustomerDeviceToken) { + cancelledBy = 'payer.'; + } else { + cancelledBy = 'payee.'; + } + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10018', + info: ('Transaction cancelled by ' + cancelledBy) + }, + 'INFO', + ('PayCode ' + existingTransaction.PayCode + ' cancelled by ' + cancelledBy)); + }); + }); + } + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ChangePIN.js b/node_server/ComServe/hJSON/ChangePIN.js new file mode 100644 index 0000000..6db7287 --- /dev/null +++ b/node_server/ComServe/hJSON/ChangePIN.js @@ -0,0 +1,101 @@ +/** + * @fileOverview Node.js ChangePIN Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Gets a list of all devices associated with a particular client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/changepin/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice) { + if (err) { + return; + } + + /** + * Check the current PIN. + */ + var timestamp = new Date(); + auth.checkDevicePIN(receivedObject.DeviceAuthorisation, existingDevice, timestamp, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING'); + return; + } + + /** + * Encrypt and store the new PIN. Create a new salt as well. + */ + auth.encryptPBKDF2(receivedObject.NewAuthorisation, function(err, newSalt, newHash) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '416', + info: ('Error encrypting new PIN: ' + err) + }, + 'WARNING'); + return; + } + + /** + * Success. Update the database with the new PIN. + */ + var newDeviceAuthorisation = config.pinCryptoVersion + '::' + newHash; + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, + { + $set: { + DeviceSalt: newSalt, + DeviceAuthorisation: newDeviceAuthorisation, + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '417', + info: 'Database offline.' + }); + return; + } + + /** + * Success! + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10057', + info: 'PIN changed.' + }, + 'INFO'); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ChangePassword.js b/node_server/ComServe/hJSON/ChangePassword.js new file mode 100644 index 0000000..36b33a0 --- /dev/null +++ b/node_server/ComServe/hJSON/ChangePassword.js @@ -0,0 +1,121 @@ +/** + * @fileOverview Node.js ChangePassword Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Allows the user to change their password if they know the old one. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/changepassword/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var config = require(global.configFile); +var templates = require(global.pathPrefix + '../utils/templates.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check the current Password. + */ + var timestamp = new Date(); + auth.checkClientPassword(receivedObject.Password, existingClient, timestamp, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING'); + return; + } + + /** + * Encrypt and store the new Password. Create a new salt as well. + */ + auth.encryptPBKDF2(receivedObject.NewPassword, function(err, newSalt, newHash) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '419', + info: ('Error encrypting new Password: ' + err) + }, + 'WARNING'); + return; + } + + /** + * Tell the user that a new device is being added - send an e-mail. + */ + var htmlEmail = templates.render('password-changed', { + DeviceNumber: existingDevice.DeviceNumber + }); + mailer.sendEmail(null, existingClient.ClientName, 'Bridge Password Changed', + htmlEmail, 'ChangePassword.process', function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '421', + info: 'Unable to send e-mail.' + }, + 'ERROR'); + return; + } + + /** + * Success. Update the database with the new Password. + */ + var newPassword = config.passwordCryptoVersion + '::' + newHash; + mainDB.updateObject(mainDB.collectionClient, {ClientID: existingClient.ClientID}, + { + $set: { + ClientSalt: newSalt, + Password: newPassword, + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '420', + info: 'Database offline.' + }); + return; + } + + /** + * Success! + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10058', + info: 'Password changed.' + }, + 'INFO'); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ConfirmInvoice.js b/node_server/ComServe/hJSON/ConfirmInvoice.js new file mode 100644 index 0000000..a35e37a --- /dev/null +++ b/node_server/ComServe/hJSON/ConfirmInvoice.js @@ -0,0 +1,243 @@ +/** + * @fileOverview Node.js Confirm Invoice Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * Confirms the transaction for the customer. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/merchant_commands/confirm_invoice/} + */ +'use strict'; +/** + * Includes. + */ +const debug = require('debug')('app:ConfirmInvoice'); +const auth = require(global.pathPrefix + 'auth.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const impl = require(global.pathPrefix + '../impl/confirm_transaction.js'); +const acqErrors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +const responsesUtils = require(global.pathPrefix + '../utils/responses.js'); + +/** + * The customer confirms the invoice, and pays it using their selected account. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Collect the data we need + */ + const data = { + TransactionID: receivedObject.InvoiceID, + TipAmount: 0, + ClientKey: receivedObject.ClientKey, + initialStatus: utils.TransactionStatus.PENDING_INVOICE, + ipAddress: functionInfo.remote, + Latitude: receivedObject.Latitude, + Longitude: receivedObject.Longitude, + AccountID: receivedObject.AccountID + }; + + /** + * Call the base implementation + */ + impl.confirmTransaction(existingClient, existingDevice, data).then(() => { + auth.respond( + res, + 200, + existingDevice, + hmacData, + functionInfo, + { + code: '10074', + info: 'Invoice confirmed.', + TransactionID: receivedObject.InvoiceID + }, + 'INFO', + 'Invoice ID <' + receivedObject.InvoiceID + '> confirmed by acquirer.' + ); + }).catch((error) => { + debug('Error:', error); + // + // Define the responses + // + const responses = [ + // + // Errors when reading from the database + // + [ + 'MongoError', + 200, 510, 'Database Offline', true + ], + + // + // Errors from the main implementation + // + [ + impl.ERRORS.TRANSACTION_NOT_FOUND, + 200, 496, 'Invalid InvoiceID' + ], + [ + impl.ERRORS.MERCHANT_NOT_FOUND, + 200, 551, 'Merchant information not found' + ], + [ + impl.ERRORS.CLIENT_DETAILS_NOT_SET, + 200, 552, 'User details not set' + ], + [ + impl.ERRORS.MERCHANT_DETAILS_NOT_SET, + 200, 553, 'Merchant details not set' + ], + [ + impl.ERRORS.CLIENT_KYC_INCOMPLETE, + 200, 554, 'Additional user information required' + ], + [ + impl.ERRORS.MERCHANT_KYC_INCOMPLETE, + 200, 555, 'Additional merchant information required' + ], + [ + impl.ERRORS.TRANSACTION_TOTAL_TOO_HIGH, + 200, 310, ('Total above current limit of ' + utils.transactionMaxText) + ], + [ + impl.ERRORS.TRANSACTION_TOTAL_TOO_LOW, + 200, 311, ('Total below current limit of ' + utils.transactionMinText) + ], + [ + impl.ERRORS.FAILED_SET_CONFIRMED, + 200, 510, 'Database offline' + ], + [ + impl.ERRORS.FAILED_SET_COMPLETE, + 200, 506, 'Database offline' + ], + [ + impl.ERRORS.FAILED_ADD_HISTORY, + 200, 507, 'Database offline' + ], + [ + impl.ERRORS.FAILED_UPDATE_CUSTOMER_BALANCE, + 200, 508, 'Database offline' + ], + [ + impl.ERRORS.FAILED_UPDATE_MERCHANT_BALANCE, + 200, 509, 'Database offline' + ], + [ + impl.ERRORS.MERCHANT_ACCOUNT_NOT_FOUND, + 200, 497, 'Invalid Merchant AccountID' + ], + [ + impl.ERRORS.CUSTOMER_ACCOUNT_NOT_FOUND, + 200, 494, 'Invalid Customer AccountID' + ], + [ + impl.ERRORS.MERCHANT_ACCOUNT_NOT_RECEIVING, + 200, 498, 'Not a receiving account' + ], + [ + impl.ERRORS.CUSTOMER_ACCOUNT_NOT_PAYMENTS, + 200, 495, 'Not a payments account' + ], + + // + // Errors from the acquirer + // + [ + acqErrors.UNKNOWN_ACQUIRER, + 200, 532, 'Merchant acquirer unknown', + true + ], + [ + acqErrors.INVALID_COMBINATION, + 200, 536, 'Invalid payment type', + true + ], + + [ + acqErrors.ACQUIRER_DOWN, + 200, 533, 'Cannot connect to acquirer', + true + ], + + [ + acqErrors.INVALID_MERCHANT_NAME, + 200, 534, 'Invalid Merchant account details.', + true + ], + [ + acqErrors.INVALID_MERCHANT_ACCOUNT_DETAILS, + 200, 535, 'Receiving account information unreadable', + true + ], + [ + acqErrors.INVALID_CARD_DETAILS, + 200, 536, 'Payment account information unreadable', + true + ], + + [ + acqErrors.ACQUIRER_UNKNOWN_ERROR, + 200, 537, 'Error processing payment', + true + ], + [ + acqErrors.ACQUIRER_BAD_REQUEST, + 200, 538, 'Error processing payment', + true + ], + [ + acqErrors.ACQUIRER_INVALID_PAYMENT_DETAILS, + 200, 540, 'Invalid payment details', + true + ], + [ + acqErrors.ACQUIRER_UNAUTHORIZED, + 200, 541, 'Merchant account unauthorized with acquirer', + true + ], + [ + acqErrors.ACQUIRER_MERCHANT_DISABLED, + 200, 542, 'Merchant account disabled with acquirer', + true + ], + [ + acqErrors.ACQUIRER_INTERNAL_SERVER_ERROR, + 200, 543, 'Error processing payment', + true + ], + + [ + acqErrors.CARD_EXPIRED, + 200, 544, 'Card has expired', + true + ], + [ + acqErrors.PAYMENT_FAILED_UNSPECIFIED, + 200, 545, 'Unspecified error', + true + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respondAuth( + res, error, existingDevice, hmacData, functionInfo, 'INFO' + ); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ConfirmTransaction.js b/node_server/ComServe/hJSON/ConfirmTransaction.js new file mode 100644 index 0000000..53d719f --- /dev/null +++ b/node_server/ComServe/hJSON/ConfirmTransaction.js @@ -0,0 +1,241 @@ +/** + * @fileOverview Node.js ConfirmTransaction Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Confirms the transaction for the customer. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/confirmtransaction/} + */ +'use strict'; + +// Legacy code line length disable. +// jscs:disable maximumLineLength +//jshint -W101 + +/** + * Includes. + */ +const log = require(global.pathPrefix + 'log.js'); +const auth = require(global.pathPrefix + 'auth.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const impl = require(global.pathPrefix + '../impl/confirm_transaction.js'); +const acqErrors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +const responsesUtils = require(global.pathPrefix + '../utils/responses.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + const data = { + TransactionID: receivedObject.TransactionID, + TipAmount: receivedObject.TipAmount, + ClientKey: receivedObject.ClientKey, + initialStatus: utils.TransactionStatus.CLAIMED, + ipAddress: functionInfo.remote + }; + + /** + * Call the base implementation + */ + impl.confirmTransaction(existingClient, existingDevice, data).then(() => { + auth.respond( + res, + 200, + existingDevice, + hmacData, + functionInfo, + { + code: '10023', + info: 'Transaction confirmed by acquirer.' + }, + 'INFO', + 'Transaction ID <' + receivedObject.TransactionID + '> confirmed by acquirer.' + ); + }).catch((error) => { + // + // Define the responses + // + const responses = [ + // + // Errors when reading from the database + // + [ + 'MongoError', + 200, 182, 'Database Offline', true + ], + + // + // Errors from the main implementation + // + [ + impl.ERRORS.TRANSACTION_NOT_FOUND, + 200, 183, 'Invalid TransactionID' + ], + [ + impl.ERRORS.MERCHANT_NOT_FOUND, + 200, 546, 'Merchant information not found' + ], + [ + impl.ERRORS.CLIENT_DETAILS_NOT_SET, + 200, 547, 'User details not set' + ], + [ + impl.ERRORS.MERCHANT_DETAILS_NOT_SET, + 200, 548, 'Merchant\'s user details not set' + ], + [ + impl.ERRORS.CLIENT_KYC_INCOMPLETE, + 200, 549, 'Additional user information required' + ], + [ + impl.ERRORS.MERCHANT_KYC_INCOMPLETE, + 200, 550, 'Additional user information for merchant required' + ], + [ + impl.ERRORS.TRANSACTION_TOTAL_TOO_HIGH, + 200, 310, ('Total above current limit of ' + utils.transactionMaxText) + ], + [ + impl.ERRORS.TRANSACTION_TOTAL_TOO_LOW, + 200, 311, ('Total below current limit of ' + utils.transactionMinText) + ], + [ + impl.ERRORS.FAILED_SET_CONFIRMED, + 200, 185, 'Database offline' + ], + [ + impl.ERRORS.FAILED_SET_COMPLETE, + 200, 185, 'Database offline' + ], + [ + impl.ERRORS.FAILED_ADD_HISTORY, + 200, 188, 'Database offline' + ], + [ + impl.ERRORS.FAILED_UPDATE_CUSTOMER_BALANCE, + 200, 239, 'Database offline' + ], + [ + impl.ERRORS.FAILED_UPDATE_MERCHANT_BALANCE, + 200, 240, 'Database offline' + ], + [ + impl.ERRORS.MERCHANT_ACCOUNT_NOT_FOUND, + 200, 209, 'Database offline' + ], + [ + impl.ERRORS.CUSTOMER_ACCOUNT_NOT_FOUND, + 200, 208, 'Database offline' + ], + [ + impl.ERRORS.MERCHANT_ACCOUNT_NOT_RECEIVING, + 200, 211, 'Invalid merchant AccountID' + ], + [ + impl.ERRORS.CUSTOMER_ACCOUNT_NOT_PAYMENTS, + 200, 210, 'Invalid customer AccountID' + ], + + // + // Errors from the acquirer + // + [ + acqErrors.UNKNOWN_ACQUIRER, + 200, 532, 'Merchant acquirer unknown', + true + ], + [ + acqErrors.INVALID_COMBINATION, + 200, 539, 'Invalid payment type', + true + ], + + [ + acqErrors.ACQUIRER_DOWN, + 200, 533, 'Cannot connect to acquirer', + true + ], + + [ + acqErrors.INVALID_MERCHANT_NAME, + 200, 534, 'Invalid merchant details', + true + ], + [ + acqErrors.INVALID_MERCHANT_ACCOUNT_DETAILS, + 200, 535, 'Receiving account information unreadable', + true + ], + [ + acqErrors.INVALID_CARD_DETAILS, + 200, 536, 'Payment account information unreadable', + true + ], + + [ + acqErrors.ACQUIRER_UNKNOWN_ERROR, + 200, 537, 'Error processing payment', + true + ], + [ + acqErrors.ACQUIRER_BAD_REQUEST, + 200, 538, 'Error processing payment', + true + ], + [ + acqErrors.ACQUIRER_INVALID_PAYMENT_DETAILS, + 200, 540, 'Invalid payment type for merchant acquirer', + true + ], + [ + acqErrors.ACQUIRER_UNAUTHORIZED, + 200, 541, 'Merchant account unauthorized with acquirer', + true + ], + [ + acqErrors.ACQUIRER_MERCHANT_DISABLED, + 200, 542, 'Merchant account disabled with acquirer', + true + ], + [ + acqErrors.ACQUIRER_INTERNAL_SERVER_ERROR, + 200, 543, 'Error processing payment', + true + ], + + [ + acqErrors.CARD_EXPIRED, + 200, 544, 'Card Expired', + true + ], + [ + acqErrors.PAYMENT_FAILED_UNSPECIFIED, + 200, 545, 'Error processing payment', + true + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respondAuth( + res, error, existingDevice, hmacData, functionInfo, 'INFO' + ); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/DeleteAccount.js b/node_server/ComServe/hJSON/DeleteAccount.js new file mode 100644 index 0000000..2c24a4f --- /dev/null +++ b/node_server/ComServe/hJSON/DeleteAccount.js @@ -0,0 +1,97 @@ +/** + * @fileOverview Node.js DeleteAccount Handler for Bridge Pay + * @preserve Copyright 2017 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Deletes the account ID sent through. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/deleteaccount/} + */ + +/** + * Includes + */ +const auth = require(global.pathPrefix + 'auth.js'); +const acquirerUtils = require(global.pathPrefix + '../utils/acquirers/acquirer.js'); +const responsesUtils = require(global.pathPrefix + '../utils/responses.js'); +const deleteAccountImpl = require(global.pathPrefix + '../impl/delete_account.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - Detail on the calling function {!name, !remote, !port}. + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + // + // Call the implementation and return the properly formatted + // + return deleteAccountImpl.deleteAccount(existingClient.ClientID, receivedObject.AccountID) + .then(() => { + /** + * Success! + */ + return auth.respond(res, 200, existingDevice, hmacData, functionInfo, + { + code: '10016', + info: 'Account successfully deleted.' + }, + 'INFO', + ('Account deleted (ID ' + receivedObject.AccountID + ').') + ); + }) + .catch((error) => { + const responses = [ + [ + deleteAccountImpl.ERRORS.RELATED_INVOICES, + 200, 30108, 'Account can\'t be deleted while related active invoices exist', true + ], + [ + // AccountID is not valid (or doesn't belong to *me*) + deleteAccountImpl.ERRORS.NOT_FOUND, + 200, 153, 'No account match.', true + ], + [ + // AccountID is not valid (or doesn't belong to *me*) + deleteAccountImpl.ERRORS.FAILED_UPDATE, + 200, 153, 'No account match.', true + ], + [ + deleteAccountImpl.ERRORS.LOCKED, + 200, 243, 'Account locked.', true + ], + [ + acquirerUtils.ERRORS.UNKNOWN_ACQUIRER, + 200, 241, 'Invalid VendorID or AcquirerName.', true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_DOWN, + 200, 244, 'Cannot connect to acquiring bank', true + ], + [ + 'MongoError', + 200, 152, 'Database offline.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + + return responseHandler.respondAuth( + res, error, existingDevice, hmacData, functionInfo, 'INFO' + ); + }) + .done(); + }); +}; diff --git a/node_server/ComServe/hJSON/DeleteAddress.js b/node_server/ComServe/hJSON/DeleteAddress.js new file mode 100644 index 0000000..7dc470c --- /dev/null +++ b/node_server/ComServe/hJSON/DeleteAddress.js @@ -0,0 +1,158 @@ +/** + * @fileOverview Node.js Delete Address Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Deletes an address which is associated with the current client. + * This call will fail if the address is used with any accounts. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/deleteaddress/} + */ + +/** + * Includes + */ +var mongodb = require('mongodb'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check to see if this address is in use. + */ + mainDB.collectionAccount.find( + { + ClientID: existingClient.ClientID, + BillingAddress: receivedObject.AddressID, + AccountStatus: {$bitsAllClear: utils.AccountDeleted} + }, + { + _id: 1, + ClientID: 1 + } + ).toArray(function(err, accounts) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '385', + info: 'Database offline.' + }); + return; + } + + /** + * Check if any addresses match the search query. + */ + if (accounts.length > 0) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '386', + info: 'Address still in use.' + }, + 'WARNING', + ('Address still in use in ' + accounts.length + ' account(s).')); + return; + } + + /** + * Get the full address detail to back up. + */ + mainDB.findOneObject(mainDB.collectionAddresses, + { + _id: mongodb.ObjectID(receivedObject.AddressID), + ClientID: existingClient.ClientID + }, + undefined, + false, + function(err, existingAddress) { + /** + * Check for errors. + */ + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '387', + info: 'Database offline.' + }); + return; + } + + /** + * Check if any addresses match the search query. + */ + if (!existingAddress) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '388', + info: 'Cannot find address to delete.' + }, + 'WARNING'); + return; + } + + /** + * Clean up address. + */ + existingAddress.AddressID = existingAddress._id.toString(); + delete existingAddress._id; + existingAddress.LastUpdate = new Date(); + existingAddress.LastVersion += 1; + + /** + * Store to AddressArchive database. + */ + mainDB.addObject(mainDB.collectionAddressArchive, existingAddress, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '389', + info: 'Database offline.' + }); + return; + } + + /** + * Delete from Address database. + */ + mainDB.removeObject(mainDB.collectionAddresses, {_id: mongodb.ObjectID(receivedObject.AddressID)}, + undefined, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '390', + info: 'Database offline.' + }); + return; + } + + /** + * Address successfully deleted. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10054', + info: 'Address deleted.' + }, + 'INFO'); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/DeleteDevice.js b/node_server/ComServe/hJSON/DeleteDevice.js new file mode 100644 index 0000000..7f528eb --- /dev/null +++ b/node_server/ComServe/hJSON/DeleteDevice.js @@ -0,0 +1,162 @@ +/** + * @fileOverview Node.js Delete Device Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Deletes a particular device if it belongs to the current client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/deletedevice/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Local variables + */ + var timestamp = new Date(); + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check that the user has permission to delete the device. + */ + auth.checkClientPassword(receivedObject.Password, existingClient, timestamp, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING'); + return; + } + + /** + * Find the device. + */ + mainDB.findOneObject(mainDB.collectionDevice, + { + _id: mongodb.ObjectId(receivedObject.DeviceIndex), + ClientID: existingClient.ClientID + }, + undefined, + false, + function(err, device) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '369', + info: 'Database offline.' + }); + return; + } + + /** + * No hits from database. + */ + if (!device) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '370', + info: 'Invalid device or device does not belong to client.' + }, + 'WARNING'); + return; + } + + /** + * Check that the selected device has not been locked by Comcarde. + */ + if (utils.bitsAllSet(device.DeviceStatus, utils.DeviceBarredMask)) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '371', + info: 'The device has been put on hold by Comcarde.' + }, + 'WARNING'); + return; + } + + /** + * Cannot delete the device that is being used. + */ + if (device.DeviceNumber === existingDevice.DeviceNumber) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '374', + info: 'Cannot delete the device currently in use.' + }, + 'WARNING'); + return; + } + + /** + * Store the old object _id as DeviceIndex + */ + device.DeviceIndex = device._id.toString(); + delete device._id; + device.DeviceAuthorisation = ''; + device.DeviceSalt = ''; + device.CurrentHMAC = ''; + device.PendingHMAC = ''; + device.LastUpdate = timestamp; + + /** + * Write the object to the Archive. + */ + mainDB.addObject(mainDB.collectionDeviceArchive, device, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '372', + info: 'Database offline.' + }); + return; + } + + /** + * The device can be safely deleted. + */ + mainDB.removeObject(mainDB.collectionDevice, + {_id: mongodb.ObjectId(receivedObject.DeviceIndex)}, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '373', + info: 'Database offline.' + }); + return; + } + + /** + * Success. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10051', + info: 'Device deleted.' + }, + 'INFO'); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/DeleteMessage.js b/node_server/ComServe/hJSON/DeleteMessage.js new file mode 100644 index 0000000..f417f2d --- /dev/null +++ b/node_server/ComServe/hJSON/DeleteMessage.js @@ -0,0 +1,132 @@ +/** + * @fileOverview Node.js Delete Message Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Deletes a particular message if it belongs to the current client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/deletemessage/} + */ + +/** + * Includes + */ +var _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mongodb = require('mongodb'); + +/** + * Deletes a Message by moving it to the MessageArchive collection + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Find the message, confirming it belongs to this client + */ + var findQuery = { + _id: mongodb.ObjectId(receivedObject.MessageID), + ClientID: existingClient.ClientID + }; + + mainDB.findOneObject(mainDB.collectionMessages, + findQuery, + undefined, + false, + function(err, message) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '486', + info: 'Database offline.' + }); + return; + } + + /** + * No hits from database. + */ + if (!message) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '489', + info: 'Invalid message or message does not belong to client.' + }, + 'WARNING'); + return; + } + + /** + * Clone the message for copying to the archive. + * Also copy _id to MessageID, remove _id, and updated the + * LastUpdate time. + */ + var messageClone = _.clone(message); + messageClone.MessageID = message._id.toHexString(); + delete messageClone._id; + + messageClone.LastUpdate = new Date(); + + /** + * Write the object to the Archive. + */ + mainDB.addObject( + mainDB.collectionMessagesArchive, + messageClone, + undefined, + false, + function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '487', + info: 'Database offline.' + }); + return; + } + + /** + * The message can be safely deleted. + */ + mainDB.removeObject( + mainDB.collectionMessages, + findQuery, + undefined, + false, + function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '488', + info: 'Database offline.' + }); + return; + } + + /** + * Success. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, + { + code: '10073', + info: 'Message deleted.' + }, + 'INFO'); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ElevateSession.js b/node_server/ComServe/hJSON/ElevateSession.js new file mode 100644 index 0000000..2fbfc68 --- /dev/null +++ b/node_server/ComServe/hJSON/ElevateSession.js @@ -0,0 +1,102 @@ +/** + * @fileOverview Elevate a session with the email and password. + * + * Requires that you are already logged in on a device. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/elevatesession/} + */ + +/** + * Includes + */ +const _ = require('lodash'); +const Q = require('q'); + +const authP = require(global.pathPrefix + 'auth-promises.js'); +const utils = require(global.pathPrefix + 'utils.js'); + +/** + * Check the email and password is correct. It also requires that you are logged + * in normally, so we also verify that the given details match those for the + * normal device login. + * + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = async function(res, functionInfo, parameters, receivedObject, hmacData) { + let authDetails = null; + try { + /** + * Check the device session is valid + */ + authDetails = await authP.validSession( + res, + receivedObject.DeviceToken, + receivedObject.SessionToken, + functionInfo, + hmacData + ).then((result) => { + return { + existingDevice: result[0], + existingClient: result[1] + }; + }).catch(() => Q.reject()); // Error has been handled in auth.ValidSession + + /** + * Check the email address sent to us is the same one as the one signed in to the device + */ + if (authDetails.existingClient.ClientName !== receivedObject.ClientName) { + throw utils.createError(559, 'Invalid ClientName.', 'ElevateSesession'); + } + + /** + * Check the password is correct for the current user + */ + const timestamp = new Date(); + await authP.checkClientPassword( + receivedObject.Password, + authDetails.existingClient, + timestamp + ); + + /** + * All good so return success + */ + authP.respond( + res, + 200, + authDetails.existingDevice, + hmacData, + functionInfo, + { + code: '10079', + info: 'Session Elevated.' + }, + 'INFO', + 'ElevateSession successful' + ); + } catch (error) { + /** + * If we have an error then return. + * NOTE: we won't have an error if it was validSession() that failed as + * it handles responding internally. + */ + if (error) { + authP.respond( + res, + 200, + _.get(authDetails, 'existingDevice', null), + hmacData, + functionInfo, + { + code: String(_.get(error, 'code', -1)), + info: _.get(error, 'message', 'Unknown error') + }, + 'ERROR', + 'ElevateSession failed' + ); + } + } +}; diff --git a/node_server/ComServe/hJSON/Get2FARequest.js b/node_server/ComServe/hJSON/Get2FARequest.js new file mode 100644 index 0000000..e5c55e0 --- /dev/null +++ b/node_server/ComServe/hJSON/Get2FARequest.js @@ -0,0 +1,112 @@ +/** + * @fileOverview Node.js Get outstanding 2-factor requests handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * Gets the client's outstanding 2 factor requests for access to the web console + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/get2farequest/} + */ + +/** + * Includes + */ +var Q = require('q'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * Gets any outstanding 2-factor requests that are created when a client tries + * to log in to the web console. These should be responded to by the apps with + * a call to Authorise2FARequest. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Request the latest outstanding 2FA request from the table + * + * Build the query. The limits are: + * - TargetAccount must match the id of the current client + * - Request must not be expired + * - Request must not be already authorised (i.e. no authorised date) + */ + var timestamp = new Date(); + var query = { + TargetAccount: existingClient.ClientID, + RequestExpiry: {$gt: timestamp}, + AuthorisedDate: {$type: 10} // Must exist and be exactly `null` (BSON Type 10) + }; + + /** + * Define the projection + */ + var projection = { + _id: 0, + RequestID: 1, + RequestDate: 1, + RequestExpiry: 1, + RequesterDisplayName: 1, + RequesterClientID: 1 + }; + + /** + * + * Build the options. Importantly, this includes a sort by RequestDate + * so that we always get the request that was made most recently (as + * there could be multiple requests that are still active). + */ + var options = { + fields: projection, + sort: {RequestDate: -1}, + comment: 'get2FARequest' // For profiler logs use + }; + + /** + * Request the object + */ + Q.nfcall( + mainDB.findOneObject, + mainDB.collectionTwoFARequests, + query, + options, + false + ).then(function(result) { + /** + * Successful query (though it may not have found anything) + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10066', + info: 'Request returned.', + Request: result // Will be null if no requests outstanding + }, + 'INFO', + ('Get2FARequest successful - ' + (result ? 'request pending' : 'no requests'))); + }).catch(function() { + /** + * Query failed, most likely from the database + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '454', + info: 'Database offline.' + }); + }); + }); // End auth.validSession callback +}; diff --git a/node_server/ComServe/hJSON/GetClientDetails.js b/node_server/ComServe/hJSON/GetClientDetails.js new file mode 100644 index 0000000..2c94bc8 --- /dev/null +++ b/node_server/ComServe/hJSON/GetClientDetails.js @@ -0,0 +1,67 @@ +/** + * @fileOverview Node.js Get Client Details Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Gets the client's personal details from the client record. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/getclientdetails/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Ensure that the client details are set. + */ + if (!(utils.bitsAllSet(existingClient.ClientStatus, utils.ClientDetailsMask))) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '425', + info: 'No details set.' + }, + 'WARNING'); + return; + } + + /** + * Success! + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10060', + info: 'Client details retrieved.', + Title: existingClient.KYC[0].Title, + FirstName: existingClient.KYC[0].FirstName, + LastName: existingClient.KYC[0].LastName, + MiddleNames: existingClient.KYC[0].MiddleNames, + ResidentialAddressID: existingClient.KYC[0].ResidentialAddressID || '', + Gender: existingClient.KYC[0].Gender || '' + }, + 'INFO'); + }); +}; diff --git a/node_server/ComServe/hJSON/GetImage.js b/node_server/ComServe/hJSON/GetImage.js new file mode 100644 index 0000000..4708a6e --- /dev/null +++ b/node_server/ComServe/hJSON/GetImage.js @@ -0,0 +1,141 @@ +/** + * @fileOverview Node.js GetImage Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Retrieves an image from the image database. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/getimage/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice) { + if (err) { + return; + } + + /** + * Check for default selfie. + */ + if (receivedObject.ImageRef === config.defaultSelfie) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10032', + info: 'Image found.', + FileType: 'PNG', + ImageFile: config.defaultSelfieData, + ImageReported: 0 + }, + 'INFO', + ('Default image sent (defaultSelfie).')); + return; + } + + /** + * Check for default Company Logo. + */ + if (receivedObject.ImageRef === config.defaultCompanyLogo0) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10032', + info: 'Image found.', + FileType: 'PNG', + ImageFile: config.defaultCompanyLogo0Data, + ImageReported: 0 + }, + 'INFO', + ('Default image sent (defaultCompanyLogo0).')); + return; + } + + /** + * The image needs to be retrieved form the object store. First, get the details from MongoDB. + */ + mainDB.findOneObject(mainDB.collectionImages, + {_id: mongodb.ObjectID(receivedObject.ImageRef)}, undefined, false, function(err, existingImage) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '221', + info: 'Database offline.' + }); + return; + } + + /** + * Check to see if the image exists. + */ + if (!existingImage) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '222', + info: 'Invalid ImageRef.' + }, + 'WARNING', + 'Client requested an invalid ImageRef.'); + return; + } + + /** + * Check to see if it has been reported. + */ + var newImageReported = 0; + if (existingImage.ImageReported !== 0) { + newImageReported = 1; + } + + /** + * Set up Bluemix transaction. + */ + config.bluemixContainer.getObject(existingImage.ImageFile) + .then(function(object) { + object.load(false) + .then(function(content) { + /** + * All good. Return it to the user. + */ + var fileString = content.toString(); + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10032', + info: 'Image found.', + FileType: existingImage.FileType, + ImageFile: fileString, + ImageReported: newImageReported + }, + 'INFO', + ('Image sent (ID ' + receivedObject.ImageRef + ', IBM OS ' + existingImage.ImageFile + ', IR' + + existingImage.ImageReported + ').')); + }); + }) + .catch(function(err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '289', + info: 'Cannot access image.' + }, + 'ERROR', + ('Cannot get image from IBM OS (' + err.message + ').')); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/GetInvoice.js b/node_server/ComServe/hJSON/GetInvoice.js new file mode 100644 index 0000000..163ebbd --- /dev/null +++ b/node_server/ComServe/hJSON/GetInvoice.js @@ -0,0 +1,148 @@ +/** + * @fileOverview Returns full details for the selected invoice + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/merchant_commands/get_invoice/} + */ + +/* + * Includes + */ +var _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var apiHelpers = require(global.pathPrefix + '../utils/api_helpers.js'); +var mongodb = require('mongodb'); + +/** + * Return the full details for the selected invoice. This only returns invoices + * where the client is the *customer*. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in invoice body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Request the invoice from the database + * + * Build the query. The limits are: + * - ClientID of the invoice must match the current client + * - _id must match the given InvoiceID + */ + var query = { + _id: mongodb.ObjectID(receivedObject.InvoiceID), + CustomerClientID: existingClient.ClientID + }; + + /** + * Define the projection + */ + var projection = { + _id: 1, + MerchantDisplayName: 1, + MerchantSubDisplayName: 1, + MerchantImage: 1, + TransactionStatus: 1, + MerchantInvoice: 1, + MerchantComment: 1, + MerchantVATNo: 1, + CustomerComment: 1, + RequestAmount: 1, + DueDate: 1, + LastUpdate: 1, + MerchantInvoiceNumber: 1 + }; + + var options = { + fields: projection, + comment: 'GetInvoice' // For profiler logs use + }; + + /** + * Find the invoice + */ + mainDB.findOneObject( + mainDB.collectionTransaction, + query, + options, + false, // Don't suppress errors + function(err, existingInvoice) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '514', + info: 'Database offline.' + }); + return; + } + + /** + * Check for no invoices. + */ + if (!existingInvoice) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '515', + info: 'Invalid InvoiceID.' + }, + 'WARNING'); + return; + } + + /** + * Add the creation date and rename the fields + */ + existingInvoice.CreationDate = existingInvoice._id.getTimestamp(); + var renames = { + _id: 'InvoiceID', + MerchantDisplayName: 'OtherDisplayName', + MerchantSubDisplayName: 'OtherSubDisplayName', + MerchantImage: 'OtherImage' + }; + apiHelpers.renameFields(existingInvoice, renames); + + // + // Copy just the InvoiceNumber up to the top level + // + if (!_.isUndefined(existingInvoice.MerchantInvoiceNumber)) { + existingInvoice.MerchantInvoiceNumber = + existingInvoice.MerchantInvoiceNumber.InvoiceNumber; + } + + // + // Set a value for any missing dates + if (_.isUndefined(existingInvoice.DueDate)) { + existingInvoice.DueDate = '1970-01-01T00:00:00.000Z'; + } + + auth.respond( + res, + 200, + existingDevice, + hmacData, + functionInfo, { + code: '10076', + info: 'Invoice returned.', + InvoiceDetail: existingInvoice + }, + 'INFO', + 'Invoice detail returned (InvoiceID ' + receivedObject.InvoiceID + ').' + ); + + }); + }); +}; diff --git a/node_server/ComServe/hJSON/GetMessage.js b/node_server/ComServe/hJSON/GetMessage.js new file mode 100644 index 0000000..de61ccb --- /dev/null +++ b/node_server/ComServe/hJSON/GetMessage.js @@ -0,0 +1,121 @@ +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Comcarde GetMessage Handler. +// +// Command = GetMessage +// Gets more information on a particular message associated with Client. +// Checked 25/5/2015 KJS +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Includes +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Request the messages from the database + * + * Build the query. The limits are: + * - ClientID of the message must match the current client + * - _id must match the given MessageID + * - TimeFilter must be before + * - Type must match the request type, if any + */ + var query = { + _id: mongodb.ObjectID(receivedObject.MessageID), + ClientID: existingClient.ClientID, + TimeFilter: {$lte: new Date()} + }; + + /** + * Define the projection + */ + var projection = { + _id: 1, + Type: 1, + Read: 1, + Reference: 1, + Info: 1, + InfoObject: 1, + LastUpdate: 1 + }; + + var options = { + fields: projection, + comment: 'GetMessage' // For profiler logs use + }; + + /** + * Find the message + */ + mainDB.findOneObject( + mainDB.collectionMessages, + query, + options, + false, + function(err, existingMessage) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '480', + info: 'Database offline.' + }); + return; + } + + /** + * Check for no messages. + */ + if (!existingMessage) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '481', + info: 'Invalid MessageID.' + }, + 'WARNING'); + return; + } + + /** + * Move _id to MessageID, add the creation date + */ + existingMessage.MessageID = existingMessage._id; + existingMessage.CreationDate = existingMessage._id.getTimestamp(); + delete existingMessage._id; + + auth.respond( + res, + 200, + existingDevice, + hmacData, + functionInfo, { + code: '10071', + info: 'Message returned.', + Message: existingMessage + }, + 'INFO', + 'Message detail returned (MessageID ' + receivedObject.MessageID + ').' + ); + + }); + }); +}; diff --git a/node_server/ComServe/hJSON/GetTransactionDetail.js b/node_server/ComServe/hJSON/GetTransactionDetail.js new file mode 100644 index 0000000..1179453 --- /dev/null +++ b/node_server/ComServe/hJSON/GetTransactionDetail.js @@ -0,0 +1,145 @@ +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Comcarde GetTransactionDetail Handler. +// +// Command = GetTransactionDetail +// Gets more information on a particular transaction associated with Client. +// Checked 25/5/2015 KJS +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Includes +var _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * If there is no timestamp object, create one. + */ + mainDB.findOneObject(mainDB.collectionTransaction, + {_id: mongodb.ObjectID(receivedObject.TransactionID)}, undefined, false, function(err, existingTransaction) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '191', + info: 'Database offline.' + }); + return; + } + + /** + * Check for no transactions. + */ + if (!existingTransaction) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '192', + info: 'Invalid TransactionID.' + }, + 'WARNING'); + return; + } + + /** + * Customer. + */ + var toReturn = {}; + if (existingTransaction.CustomerClientID === existingClient.ClientID) { + toReturn.OtherDisplayName = existingTransaction.MerchantDisplayName; + toReturn.OtherSubDisplayName = existingTransaction.MerchantSubDisplayName; + toReturn.OtherImage = existingTransaction.MerchantImage; + toReturn.TransactionStatus = existingTransaction.TransactionStatus; + toReturn.StatusInfo = existingTransaction.StatusInfo; + toReturn.MerchantInvoice = existingTransaction.MerchantInvoice; + toReturn.MerchantComment = existingTransaction.MerchantComment; + toReturn.MerchantVATNo = existingTransaction.MerchantVATNo; + toReturn.RequestAmount = existingTransaction.RequestAmount; + toReturn.TipAmount = existingTransaction.TipAmount; + toReturn.TotalAmount = existingTransaction.TotalAmount; + toReturn.MyLocation = existingTransaction.CustomerLocation; + toReturn.SaleTime = existingTransaction.SaleTime; + + // + // Copy just the InvoiceNumber up to the top level + // + if (!_.isUndefined(existingTransaction.MerchantInvoiceNumber)) { + toReturn.MerchantInvoiceNumber = + existingTransaction.MerchantInvoiceNumber.InvoiceNumber; + } + + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10026', + info: 'Detail returned.', + TransactionDetail: toReturn + }, + 'INFO', + ('Transaction detail returned (TransactionID ' + mongodb.ObjectID(existingTransaction._id).toString() + ').')); + return; + } + + /** + * Merchant. + */ + if (existingTransaction.MerchantClientID === existingClient.ClientID) { + toReturn.OtherDisplayName = existingTransaction.CustomerDisplayName; + toReturn.OtherSubDisplayName = existingTransaction.CustomerSubDisplayName; + toReturn.OtherImage = existingTransaction.CustomerImage; + toReturn.TransactionStatus = existingTransaction.TransactionStatus; + toReturn.StatusInfo = existingTransaction.StatusInfo; + toReturn.MerchantInvoice = existingTransaction.MerchantInvoice; + toReturn.MerchantComment = existingTransaction.MerchantComment; + toReturn.MerchantVATNo = existingTransaction.MerchantVATNo; + toReturn.RequestAmount = existingTransaction.RequestAmount; + toReturn.TipAmount = existingTransaction.TipAmount; + toReturn.TotalAmount = existingTransaction.TotalAmount; + toReturn.MyLocation = existingTransaction.MerchantLocation; + toReturn.SaleTime = existingTransaction.SaleTime; + + // + // Copy just the InvoiceNumber up to the top level + // + if (!_.isUndefined(existingTransaction.MerchantInvoiceNumber)) { + toReturn.MerchantInvoiceNumber = + existingTransaction.MerchantInvoiceNumber.InvoiceNumber; + } + + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10026', + info: 'Detail returned.', + TransactionDetail: toReturn + }, + 'INFO', + ('Transaction detail returned (TransactionID ' + mongodb.ObjectID(existingTransaction._id).toString() + ').')); + return; + } + + /** + * Didn't find the client name. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '193', + info: 'Invalid ClientID.' + }, + 'WARNING'); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/GetTransactionHistory.js b/node_server/ComServe/hJSON/GetTransactionHistory.js new file mode 100644 index 0000000..cd8eef5 --- /dev/null +++ b/node_server/ComServe/hJSON/GetTransactionHistory.js @@ -0,0 +1,115 @@ +/** + * @fileOverview Node.js GetTransactionHistory Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Gets a high level list of all the transactions associated with a Client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/gettransactionhistory/} + */ + +/** + * Includes + */ +var _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * If there is no timestamp object, create one. + */ + var timestamp; + if (receivedObject.Timestamp) { + timestamp = new Date(receivedObject.Timestamp); + } else { + timestamp = new Date(); + } + + /** + * Create the account filter if needed. + */ + var accountFilter = ''; + if (receivedObject.AccountID) { + accountFilter = receivedObject.AccountID; + } else { + accountFilter = ''; + } + + /** + * Search for the requested items. + */ + mainDB.collectionTransactionHistory.find( + { + ClientID: existingClient.ClientID, + AccountID: {$regex: accountFilter}, + SaleTime: {$lt: timestamp} + }, + { + _id: 0, + TransactionID: 1, + TransactionType: 1, + AccountID: 1, + OtherDisplayName: 1, + OtherSubDisplayName: 1, + OtherImage: 1, + MyLocation: 1, + TotalAmount: 1, + SaleTime: 1, + MerchantInvoiceNumber: 1 + } + ).skip(receivedObject.Skip).limit(receivedObject.Number).sort({'SaleTime': -1}).toArray(function(err, items) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '187', + info: 'Database offline.' + }); + return; + } + + // + // Copy just the InvoiceNumber up to the top level + // + for (var i = 0; i < items.length; ++i) { + if (!_.isUndefined(items[i].MerchantInvoiceNumber)) { + items[i].MerchantInvoiceNumber = + items[i].MerchantInvoiceNumber.InvoiceNumber; + } + } + + /** + * Success! + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10025', + info: 'History returned.', + count: items.length, + TransactionHistory: items + }, + 'INFO', + ('Transaction history requested from ' + (receivedObject.Skip + 1) + ' to ' + + (receivedObject.Skip + receivedObject.Number) + ' (' + items.length + ' items found).')); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/GetTransactionUpdate.js b/node_server/ComServe/hJSON/GetTransactionUpdate.js new file mode 100644 index 0000000..b853d36 --- /dev/null +++ b/node_server/ComServe/hJSON/GetTransactionUpdate.js @@ -0,0 +1,50 @@ +/** + * @fileOverview Node.js GetTransactionUpdate Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Allows a user to check a transaction to see what's happening. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/gettransactionupdate/} + */ + +/** + * Includes + */ +var auth = require(global.pathPrefix + 'auth.js'); +var impl = require(global.pathPrefix + '../impl/get_transaction_update.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice) { + if (err) { + return; + } + + impl.getTransactionUpdate(receivedObject, function(err, result) { + // + // The error or result will be the correct body to send back + // to the caller + // + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, err); + } else { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, result); + } + }); + }); +}; diff --git a/node_server/ComServe/hJSON/IconCache.js b/node_server/ComServe/hJSON/IconCache.js new file mode 100644 index 0000000..fc0eb5b --- /dev/null +++ b/node_server/ComServe/hJSON/IconCache.js @@ -0,0 +1,61 @@ +/** + * @fileOverview Node.js IconCache Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Returns current icon versions. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/iconcache/} + */ + +/** + * Includes + */ +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); + +var cardIconCache = [ + {ImageName: 'AMEX.png', VersionNo: '1'}, + {ImageName: 'bridge-card.png', VersionNo: '2'}, + {ImageName: 'BRIDGE_MERCHANT.png', VersionNo: '1'}, + {ImageName: 'CARTEBLEUE.png', VersionNo: '1'}, + {ImageName: 'credorax-account.png', VersionNo: '3'}, + {ImageName: 'Dankort.png', VersionNo: '1'}, + {ImageName: 'DINERS.png', VersionNo: '1'}, + {ImageName: 'Diners-Generic.png', VersionNo: '1'}, + {ImageName: 'Discover-card.png', VersionNo: '2'}, + {ImageName: 'Electron.png', VersionNo: '1'}, + {ImageName: 'Generic-card.png', VersionNo: '1'}, + {ImageName: 'JCB.png', VersionNo: '1'}, + {ImageName: 'LloydsTSB.png', VersionNo: '2'}, + {ImageName: 'MAESTRO.png', VersionNo: '2'}, + {ImageName: 'MASTERCARD_CORPORATE_CREDIT.png', VersionNo: '1'}, + {ImageName: 'MASTERCARD_CORPORATE_DEBIT.png', VersionNo: '1'}, + {ImageName: 'MASTERCARD_CREDIT.png', VersionNo: '1'}, + {ImageName: 'MASTERCARD_DEBIT.png', VersionNo: '1'}, + {ImageName: 'MIR.png', VersionNo: '1'}, + {ImageName: 'RBS.png', VersionNo: '2'}, + {ImageName: 'VISA_CORPORATE_CREDIT.png', VersionNo: '1'}, + {ImageName: 'VISA_CORPORATE_DEBIT.png', VersionNo: '1'}, + {ImageName: 'VISA_CREDIT.png', VersionNo: '1'}, + {ImageName: 'VISA_DEBIT.png', VersionNo: '1'}, + {ImageName: 'worldpay-account.png', VersionNo: '2'} +]; + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + */ +exports.process = function(res, functionInfo) { + auth.respond(res, 200, null, null, functionInfo, { + code: '10017', + info: 'IconCache check successful.', + cache: cardIconCache + }, + 'INFO', + 'Icon cache sent.'); +}; diff --git a/node_server/ComServe/hJSON/ImageCache.js b/node_server/ComServe/hJSON/ImageCache.js new file mode 100644 index 0000000..aca8386 --- /dev/null +++ b/node_server/ComServe/hJSON/ImageCache.js @@ -0,0 +1,78 @@ +/** + * @fileOverview Node.js Image Cache Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Retrieves an image from the image database. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/imagecache/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Build the image cache. + */ + var imageCache = []; + imageCache.push({ + ImageType: 'defaultSelfie', + ImageRef: config.defaultSelfie + }); + imageCache.push({ + ImageType: 'Selfie', + ImageRef: existingClient.Selfie + }); + + /** + * Add Merchant logos if applicable. + */ + if (existingClient.Merchant[0].MerchantStatus === 1) { + imageCache.push({ + ImageType: 'defaultCompanyLogo0', + ImageRef: config.defaultCompanyLogo0 + }); + imageCache.push({ + ImageType: 'CompanyLogo0', + ImageRef: existingClient.Merchant[0].CompanyLogo + }); + } + + /** + * Return current images from the cache. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10033', + info: 'ImageCache check successful.', + Images: imageCache + }, + 'INFO', + 'Image cache sent.'); + }); +}; diff --git a/node_server/ComServe/hJSON/KeepAlive.js b/node_server/ComServe/hJSON/KeepAlive.js new file mode 100644 index 0000000..4872b85 --- /dev/null +++ b/node_server/ComServe/hJSON/KeepAlive.js @@ -0,0 +1,47 @@ +/** + * @fileOverview Node.js KeepAlive Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Simple refresh of the session token. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/keepalive/} + */ + +/** + * Includes + */ +var auth = require(global.pathPrefix + 'auth.js'); +var log = require(global.pathPrefix + 'log.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice) { + if (err) { + return; + } + + /** + * Indicate that the KeepAlive was successful. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10028', + info: 'Keep alive successful.' + }, + 'INFO'); + }); +}; diff --git a/node_server/ComServe/hJSON/ListAccounts.js b/node_server/ComServe/hJSON/ListAccounts.js new file mode 100644 index 0000000..f44eedf --- /dev/null +++ b/node_server/ComServe/hJSON/ListAccounts.js @@ -0,0 +1,281 @@ +/** + * @fileOverview Node.js List Accounts Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Returns a list of accounts for the referenced user. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/listaccounts/} + */ +'use strict'; + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var config = require(global.configFile); +var anon = require(global.pathPrefix + '../utils/anon.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check the EULA version. + */ + if (existingClient.EULAVersionAccepted !== config.EULAVersion) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '293', + info: 'EULA must be accepted before continuing.' + }, + 'WARNING'); + return; + } + + /** + * Session login successful. Get an account list from the database (includes all data, + * except accounts that were create from the API) + * Cyclomatic complexity warning due to legacy code. + */ + //jshint -W074, -W071 + const query = { + ClientID: existingClient.ClientID, + AccountStatus: { + $bitsAllClear: utils.AccountApiCreated + } + }; + mainDB.collectionAccount.find(query).toArray(function(err, items) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '121', + info: 'Database offline.' + }); + return; + } + + /** + * Client has no accounts in the system. + */ + if (items === null) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '122', + info: 'No Items.' + }, + 'INFO', + 'No accounts.'); + return; + } + + /** + * Filter the account information. + */ + var counter = 0; + var newArray = []; + var newAccount = {}; + var issues = []; + + /** + * Deal with listing of deleted accounts only. + */ + var listDeleted = 0; + if (parameters.Command === 'ListDeletedAccounts') { + listDeleted = utils.AccountDeleted; + } + + /** + * Go through each item and return a subset of information. + */ + while (counter < items.length) { + /** + * Check for Merchant status: only merchants can see receiving accounts. + * Don't show if it is deleted (unless we are asked to show deleted only). + * The disable for JS Hint is because there is a bitwise comparison. + */ + //jshint -W016 + if (((existingClient.Merchant[0].MerchantStatus === 1) || + (items[counter].AccountType !== 'Credit/Debit Receiving Account')) && + ((items[counter].AccountStatus & utils.AccountDeleted) === listDeleted)) { + //jshint +W016 + /** + * Select card information. + */ + newAccount = {}; + newAccount.AccountID = items[counter]._id; + newAccount.AccountType = items[counter].AccountType; + newAccount.ClientAccountName = items[counter].ClientAccountName; + newAccount.BillingAddress = items[counter].BillingAddress; + newAccount.VendorID = items[counter].VendorID; + newAccount.VendorAccountName = items[counter].VendorAccountName; + newAccount.NameOnAccount = items[counter].NameOnAccount; + newAccount.ReceivingAccount = items[counter].ReceivingAccount; + newAccount.PaymentsAccount = items[counter].PaymentsAccount; + newAccount.BalanceAvailable = items[counter].BalanceAvailable; + if (newAccount.BalanceAvailable) { + newAccount.Balance = items[counter].Balance; + } + newAccount.IconLocation = items[counter].IconLocation; + newAccount.Integrity = items[counter].Integrity; + + /** + * Watch out for merchant images should the person have previously been a Merchant. + */ + if (existingClient.Merchant[0].MerchantStatus === 0) { + if ((items[counter].UserImage === 'defaultCompanyLogo0') || + (items[counter].UserImage === 'CompanyLogo0')) { + newAccount.UserImage = 'defaultSelfie'; + } else { + newAccount.UserImage = items[counter].UserImage; + } + } else { + newAccount.UserImage = items[counter].UserImage; + } + + /** + * Pull and verify information and account access. + * Note that only the *Encrypted fields (e.g. CardPANEncrypted) store the actual details. + * No sensitive information is ever stored as plain text. + */ + issues = []; + if (newAccount.AccountType === 'Credit/Debit Payment Card') { + /** + * Check the card details using the keys. + */ + var CardPANEncryptedResult = null; + var CardValidFromEncryptedResult = null; + var CardExpiryEncryptedResult = null; + var IssueNumberEncryptedResult = null; + + /** + * Check the card details first. + * Note that detailed error reporting is coming back but it is ignored for the moment. + * Please check the logs for this information. + */ + if (items[counter].CardPANEncrypted !== '') { + CardPANEncryptedResult = utils.checkAccountInformation( + items[counter].CardPANEncrypted, + receivedObject.ClientKey, + existingClient._id.toString(), + items[counter]._id.toString(), + 'CardPANEncrypted'); + if (CardPANEncryptedResult) { + issues.push(utils.ACCOUNT_ERR.CARD_PAN_DEC); + } + } + if (items[counter].CardValidFromEncrypted !== '') { + CardValidFromEncryptedResult = utils.checkAccountInformation( + items[counter].CardValidFromEncrypted, + receivedObject.ClientKey, + existingClient._id.toString(), + items[counter]._id.toString(), + 'CardValidFromEncrypted'); + if (CardValidFromEncryptedResult) { + issues.push(utils.ACCOUNT_ERR.CARD_VALID_DEC); + } + } + if (items[counter].CardExpiryEncrypted !== '') { + CardExpiryEncryptedResult = utils.checkAccountInformation( + items[counter].CardExpiryEncrypted, + receivedObject.ClientKey, + existingClient._id.toString(), + items[counter]._id.toString(), + 'CardExpiryEncrypted'); + if (CardExpiryEncryptedResult) { + // Nesting too deep; functionality here for now for readability. + //jshint -W073 + if (CardExpiryEncryptedResult.code === 5) { + issues.push(CardExpiryEncryptedResult.message); + } else { + issues.push(utils.ACCOUNT_ERR.CARD_EXP_DEC); + } + //jshint +W073 + } + } + if (items[counter].IssueNumberEncrypted !== '') { + IssueNumberEncryptedResult = utils.checkAccountInformation( + items[counter].IssueNumberEncrypted, + receivedObject.ClientKey, + existingClient._id.toString(), + items[counter]._id.toString(), + 'IssueNumberEncrypted'); + if (IssueNumberEncryptedResult) { + issues.push(utils.ACCOUNT_ERR.CARD_ISS_DEC); + } + } + + /** + * Add the anonymised data. + */ + newAccount.CardPAN = items[counter].CardPAN; + } else if (newAccount.AccountType === 'Bank Account') { + newAccount.AccountNumber = items[counter].AccountNumber; + newAccount.SortCode = items[counter].SortCode; + } else if (newAccount.AccountType === 'Credit/Debit Receiving Account') { + var AcquirerMerchantIDEncryptedResult = null; + if (items[counter].AcquirerMerchantID !== '') { + AcquirerMerchantIDEncryptedResult = utils.decryptDataV1(items[counter].AcquirerMerchantID); + if (typeof AcquirerMerchantIDEncryptedResult === 'string') { + newAccount.AcquirerMerchantID = AcquirerMerchantIDEncryptedResult; + } else { + issues.push(utils.ACCOUNT_ERR.CARD_ISS_DEC); + } + } + } + + /** + * Add the responses. + */ + if (issues.length !== 0) { + if (newAccount.Integrity !== null) { + newAccount.Integrity = newAccount.Integrity.concat(issues); + } else { + newAccount.Integrity = issues; + } + } + + /** + * Push onto new array subset. + */ + newArray.push(newAccount); + } + + /** + * Always increment the counter. + */ + counter++; + } + + /** + * Return the account information. + */ + var defaultAccount = existingDevice.DefaultAccount; + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10013', + info: 'Accounts list sent.', + DefaultAccount: defaultAccount, + AccountList: newArray + }, + 'INFO'); + }); + //jshint +W074, +W071 + }); +}; diff --git a/node_server/ComServe/hJSON/ListAddresses.js b/node_server/ComServe/hJSON/ListAddresses.js new file mode 100644 index 0000000..0dcf33b --- /dev/null +++ b/node_server/ComServe/hJSON/ListAddresses.js @@ -0,0 +1,95 @@ +/** + * @fileOverview Node.js List Addresses Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Gets a list of all addresses associated with a particular client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/listaddresses/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); +var anon = require(global.pathPrefix + '../utils/anon.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Search for the requested addresses. + */ + mainDB.collectionAddresses.find( + { + ClientID: existingClient.ClientID + }, + { + _id: 1, + AddressDescription: 1, + BuildingNameFlat: 1, + Address1: 1, + Address2: 1, + Town: 1, + County: 1, + PostCode: 1, + Country: 1, + PhoneNumber: 1, + ResidentTo: 1, + ResidentFrom: 1 + } + ).toArray(function(err, addresses) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '377', + info: 'Database offline.' + }); + return; + } + + /** + * Clean up device number detail. + */ + var counter; + for (counter = 0; counter < addresses.length; counter++) { + addresses[counter].AddressID = addresses[counter]._id.toString(); + delete addresses[counter]._id; + anon.anonymiseAddress(addresses[counter]); + } + + /** + * Success! + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10052', + info: 'Address list returned.', + AddressCount: addresses.length, + MaxAddresses: config.maxAddresses, + Addresses: addresses + }, + 'INFO', + ('Address list requested: ' + addresses.length + ' found (Max. ' + config.maxAddresses + ').')); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ListDevices.js b/node_server/ComServe/hJSON/ListDevices.js new file mode 100644 index 0000000..7da0f1c --- /dev/null +++ b/node_server/ComServe/hJSON/ListDevices.js @@ -0,0 +1,93 @@ +/** + * @fileOverview Node.js List Devices Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Gets a list of all devices associated with a particular client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/listdevices/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var anon = require(global.pathPrefix + '../utils/anon.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Search for the requested items. + */ + mainDB.collectionDevice.find( + { + ClientID: existingClient.ClientID + }, + { + _id: 1, + DeviceName: 1, + DeviceNumber: 1, + DeviceStatus: 1, + DeviceHardware: 1, + DeviceSoftware: 1, + LastLoginLocation: 1, + LastLoginIP: 1, + LastLogin: 1, + LastUpdate: 1 + } + ).toArray(function(err, items) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '362', + info: 'Database offline.' + }); + return; + } + + /** + * Clean up device number detail. + */ + var counter = 0; + while (counter < items.length) { + items[counter].DeviceIndex = items[counter]._id; + delete items[counter]._id; + anon.anonymiseDevice(items[counter]); + counter++; + } + + /** + * Success! + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10050', + info: 'Device list returned.', + DeviceCount: items.length, + MaxDevices: existingClient.MaxDevices, + Devices: items + }, + 'INFO', + ('Device list requested: ' + items.length + ' device(s) found (Max. ' + existingClient.MaxDevices + ').')); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ListInvoices.js b/node_server/ComServe/hJSON/ListInvoices.js new file mode 100644 index 0000000..8410340 --- /dev/null +++ b/node_server/ComServe/hJSON/ListInvoices.js @@ -0,0 +1,149 @@ +/** + * @fileOverview List the invoices that are outstanding for the logged in user to pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/merchant_commands/list_invoices/} + */ + +/** + * Includes + */ +var _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var apiHelpers = require(global.pathPrefix + '../utils/api_helpers.js'); + +/** + * List all invoices for the current client. This is oly the invoices that the + * client is the *customer* for. Invoices where the client is the *merchant* + * can be accessd through the console. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Request the list of invoices from the database + * + * Build the query. The limits are: + * - ClientID of the invoice must match the current client + * - Must have an invoice number + */ + var query = { + CustomerClientID: existingClient.ClientID, + MerchantInvoiceNumber: { + $exists: true + } + }; + + /** + * If we've been given a last modified date, then we want only invoices + * modified since then. + */ + if (receivedObject.ModifiedSince) { + query.LastUpdate = { + $gte: new Date(receivedObject.ModifiedSince) + }; + } + + /** + * Define the projection + */ + var projection = { + _id: 1, + TransactionStatus: 1, + MerchantDisplayName: 1, + MerchantSubDisplayName: 1, + MerchantImage: 1, + RequestAmount: 1, + DueDate: 1, + LastUpdate: 1, + MerchantInvoiceNumber: 1 + }; + + /** + * Define the Skip and Number or defaults + */ + var skip = receivedObject.Skip || 0; + var number = receivedObject.number || 30; + + /** + * Get all available invoices as an array. + * Note that we don't pass a callback to toArray() so it instead + * returns a promise that we handle with the standard then/catch + */ + mainDB.collectionTransaction + .find(query) + .sort({LastUpdate: -1}) + .skip(skip) + .limit(number) + .project(projection) + .toArray() + .then(function(invoices) { + /** + * Successful query (though it may not have found anything) + * Need to iterate through the results, getting the + * CreationDate from the _id, and doing all the renames. + */ + const renames = { + _id: 'InvoiceID', + MerchantDisplayName: 'OtherDisplayName', + MerchantSubDisplayName: 'OtherSubDisplayName', + MerchantImage: 'OtherImage' + }; + _.each(invoices, function(invoice) { + invoice.CreationDate = invoice._id.getTimestamp(); + apiHelpers.renameFields(invoice, renames); + + // + // Copy just the InvoiceNumber up to the top level + // + if (!_.isUndefined(invoice.MerchantInvoiceNumber)) { + invoice.MerchantInvoiceNumber = + invoice.MerchantInvoiceNumber.InvoiceNumber; + } + + // + // Fix any missing DueDates + // + if (_.isUndefined(invoice.DueDate)) { + invoice.DueDate = '1970-01-01T00:00:00.000Z'; + } + }); + auth.respond(res, 200, existingDevice, hmacData, functionInfo, + { + code: '10075', + info: 'Invoices list sent.', + count: invoices.length, + InvoiceList: invoices + }, + 'INFO', + 'ListInvoices successful.' + ); + }).catch(function() { + /** + * Query failed, most likely from the database + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '512', + info: 'Database offline.' + }); + }); + }); // End auth.validSession callback +}; diff --git a/node_server/ComServe/hJSON/ListItems.js b/node_server/ComServe/hJSON/ListItems.js new file mode 100644 index 0000000..c9e9d92 --- /dev/null +++ b/node_server/ComServe/hJSON/ListItems.js @@ -0,0 +1,140 @@ +/** + * @fileOverview List a merchant's pre-configured items for use in the POS + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/merchant_commands/list_items/} + */ + +/** + * Includes + */ +var _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * List all items for the current client. This will only be successful if the + * client is an active merchant. If not they will receive an error message. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /* + * Check the user is an active merchant + */ + if ( + !_.isArray(existingClient.Merchant) || + !existingClient.Merchant.length > 0 || + existingClient.Merchant[0].MerchantStatus !== utils.MerchantStatusActive + ) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '470', + info: 'Not a merchant' + }, + 'WARNING'); + return; + } + + /** + * Request the list of items from the database + * + * Build the query. The limits are: + * - ClientID of the item must match the current client + * - ItemStatus must be active unless we have a ModifiedSince date + */ + var query = { + ClientID: existingClient.ClientID + }; + + /** + * If we've been given a last modified date, then we want only items + * modified since then, and we want to include all items (including + * deleted) so that an app can work out the changes. + * + * If we don't, we only want the Active items as it is just a + * basic list of items requested. + */ + if (receivedObject.ModifiedSince) { + query.LastUpdate = { + $gte: new Date(receivedObject.ModifiedSince) + }; + } else { + query.ItemStatus = utils.ItemStatusActive; + } + + /** + * Define the projection + */ + var projection = { + _id: 1, + BridgeID: 1, + ItemStatus: 1, + ItemCode: 1, + Description: 1, + Tags: 1, + VATCode: 1, + VATRate: 1, + NetAmount: 1, + GrossAmount: 1, + ImageID: 1, + LoyaltyPoints: 1, + LastUpdate: 1 + }; + + /** + * Get all available items as an array. + * Note that we don't pass a callback to toArray() so it instead + * returns a promise that we handle with the standard then/catch + */ + mainDB.collectionItems + .find(query) + .project(projection) + .toArray() + .then(function(items) { + /** + * Successful query (though it may not have found anything) + * Need to iterate through the results, moving _id to ItemID + * before we return them + */ + _.each(items, function(item) { + item.ItemID = item._id; + delete item._id; + }); + auth.respond(res, 200, existingDevice, hmacData, functionInfo, + { + code: '10069', + info: 'Items list sent.', + ItemList: items + }, + 'INFO', + 'ListItems successful' + ); + }).catch(function() { + /** + * Query failed, most likely from the database + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '469', + info: 'Database offline.' + }); + }); + }); // End auth.validSession callback +}; diff --git a/node_server/ComServe/hJSON/ListMessages.js b/node_server/ComServe/hJSON/ListMessages.js new file mode 100644 index 0000000..6043286 --- /dev/null +++ b/node_server/ComServe/hJSON/ListMessages.js @@ -0,0 +1,120 @@ +/** + * @fileOverview List messages for this client + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/messaging_commands/listmessages/} + */ + +/** + * Includes + */ +var _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * List all messages for the current client, or just those of the Type passed in. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Request the list of messages from the database + * + * Build the query. The limits are: + * - ClientID of the message must match the current client + * - TimeFilter must be before + * - Type must match the request type, if any + */ + var query = { + ClientID: existingClient.ClientID, + TimeFilter: {$lte: new Date()} + }; + + /** + * If we've been given a Type, filter by that type + * + * If we don't, we only want the Active messages as it is just a + * basic list of messages requested. + */ + if (receivedObject.Type) { + query.Type = receivedObject.Type; + } + + /** + * Define the projection + */ + var projection = { + _id: 1, + Type: 1, + Read: 1, + Info: 1 + }; + + /** + * Check the Skip and Number items + */ + var skip = receivedObject.Skip || 0; + var limit = receivedObject.Number || 0; + + /** + * Get all available messages as an array. + * Note that we don't pass a callback to toArray() so it instead + * returns a promise that we handle with the standard then/catch + */ + mainDB.collectionMessages + .find(query) + .skip(skip) + .limit(limit) + .project(projection) + .toArray() + .then(function(messages) { + /** + * Successful query (though it may not have found anything) + * Need to iterate through the results, moving _id to MessageID + * and adding a timestamp before we return them + */ + _.each(messages, function(message) { + message.MessageID = message._id; + message.CreationDate = message._id.getTimestamp(); + delete message._id; + }); + auth.respond(res, 200, existingDevice, hmacData, functionInfo, + { + code: '10070', + info: 'Messages list sent.', + count: messages.length, + MessageList: messages + }, + 'INFO', + 'ListMessages successful' + ); + }).catch(function() { + /** + * Query failed, most likely from the database + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '478', + info: 'Database offline.' + }); + }); + }); // End auth.validSession callback +}; diff --git a/node_server/ComServe/hJSON/LogOut1.js b/node_server/ComServe/hJSON/LogOut1.js new file mode 100644 index 0000000..c84791b --- /dev/null +++ b/node_server/ComServe/hJSON/LogOut1.js @@ -0,0 +1,94 @@ +/** + * @fileOverview Node.js Log Out Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Removes the session token and requires a login before proceeding. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/logout1/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Reset the session token. + */ + var timestamp = new Date(); + var logoutData = {}; + logoutData.ClientID = existingDevice.ClientID; + logoutData.DeviceToken = existingDevice.DeviceToken; + logoutData.SessionToken = existingDevice.SessionToken; + logoutData.SourceIP = functionInfo.remote; + logoutData.OperationType = 'LogOut'; + logoutData.DateTime = timestamp; + mainDB.updateObject(mainDB.collectionDevice, {_id: mongodb.ObjectID(existingDevice._id)}, { + $set: { + SessionToken: '', + SessionTokenExpiry: timestamp + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '214', + info: 'Database offline.' + }); + return; + } + + /** + * Store logout data. + */ + mainDB.addObject(mainDB.collectionBridgeLogin, logoutData, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '225', + info: 'Database offline.' + }); + return; + } + + /** + * Return successful logout to the user. + */ + var userName = existingClient.DisplayName; + if (userName === '') { + userName = '[New User]'; + } + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10030', + info: 'LogOut1 successful.' + }, + 'INFO', + (userName + ' logged out.')); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Login1.js b/node_server/ComServe/hJSON/Login1.js new file mode 100644 index 0000000..b5212b1 --- /dev/null +++ b/node_server/ComServe/hJSON/Login1.js @@ -0,0 +1,337 @@ +/** + * @fileOverview Node.js Add Login Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Log in to the system and issues a session token. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/login1/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); +var crypto = require('crypto'); +var async = require('async'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {!object} hmacData - hmac information {!address, !method, !body, ?timestamp, ?hmac} + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Valid login request. + * Check API version. + */ + if (receivedObject.APIVersion.split('.')[0] !== config.CCServerVersion.split('.')[0]) { + auth.respond(res, 200, null, null, functionInfo, { + code: '47', + info: 'A major revision change has occurred. The App must be updated.' + }, + 'WARNING', + 'Obsolete App version.', + ('AI [' + receivedObject.ClientName + ']')); + return; + } + + /** + * Find the device and check the token. + */ + mainDB.findOneObject(mainDB.collectionDevice, {DeviceToken: receivedObject.DeviceToken}, undefined, false, + function(err, existingDevice) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '41', + info: 'Database offline.' + }); + return; + } + + /** + * Check that the device was found. + */ + if (existingDevice === null) { + auth.respond(res, 200, null, null, functionInfo, { + code: '42', + info: 'Invalid device token.' + }, + 'ERROR', + 'Mobile device cannot be matched to token.', + ('AF [' + receivedObject.ClientName + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Check device status. + */ + var currentDeviceStatus = auth.checkDeviceStatus(existingDevice.DeviceStatus); + if (currentDeviceStatus) { + auth.respond(res, 200, null, null, functionInfo, { + code: currentDeviceStatus.code.toString(), + info: currentDeviceStatus.message + }, + 'WARNING', + null, + ('AI [' + receivedObject.ClientName + ' (' + existingDevice.DeviceNumber + ')]')); + return; + } + + /** + * Check the passcode - 5 digit pin minimum. + */ + var timestamp = new Date(); + auth.checkDevicePIN(receivedObject.DeviceAuthorisation, existingDevice, timestamp, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING', + null, + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')')); + return; + } + + // Next pull the owning client based on the ID stored with the device. + mainDB.findOneObject(mainDB.collectionClient, {ClientID: existingDevice.ClientID}, undefined, false, + function(err, existingClient) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '45', + info: 'Database offline.' + }); + return; + } + + /** + * User name not found. + */ + if (existingClient === null) { + auth.respond(res, 200, null, null, functionInfo, { + code: '47', + info: 'Invalid client e-mail.' + }, + 'WARNING', + null, + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')')); + return; + } + + // Check the email we were passed matches that of the owning client + if (receivedObject.ClientName !== existingClient.ClientName) { + auth.respond(res, 200, null, null, functionInfo, { + code: '46', + info: 'E-mail mismatch.' + }, + 'WARNING', + ('E-mail mismatch in received data (' + receivedObject.ClientName + ').'), + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')')); + return; + } + + /** + * Check client status. + */ + var currentClientStatus = auth.checkClientStatus(existingClient.ClientStatus); + if (currentClientStatus) { + auth.respond(res, 200, null, null, functionInfo, { + code: currentClientStatus.code.toString(), + info: currentClientStatus.message + }, + 'WARNING', + null, + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')')); + return; + } + + /** + * Check the HMAC. + * We have to add the ClientName into HMAC data before checking + */ + hmacData.ClientName = existingClient.ClientName; + auth.checkHMAC(existingDevice, hmacData, 'Login1.process', function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING', + null, + (existingDevice.ClientID + ' (' + existingDevice.DeviceNumber + ')')); + return; + } + + /** + * Store the last login location if available. + */ + var newLastLoginLocation = null; + if ((receivedObject.Longitude !== null) && (receivedObject.Latitude !== null)) { + newLastLoginLocation = { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }; + } + + /** + * Activate the session token and store a successful login. + */ + var newExpiry = new Date(timestamp); + newExpiry.setMinutes(newExpiry.getMinutes() + utils.sessionTimeout); + var newDeviceHardware = receivedObject.DeviceHardware; + var newDeviceSoftware = receivedObject.DeviceSoftware; + var sessionToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: receivedObject.DeviceToken}, { + $set: { + DeviceHardware: newDeviceHardware, + DeviceSoftware: newDeviceSoftware, + LastUpdate: timestamp, + SessionToken: sessionToken, + SessionTokenExpiry: newExpiry, + LastLoginLocation: newLastLoginLocation, + LastLoginIP: functionInfo.remote, + LastLogin: timestamp, + LoginAttempts: 0 + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '49', + info: 'Database offline.' + }); + return; + } + + /** + * Store the login time and location. + */ + var loginData = {}; + loginData.ClientID = existingClient.ClientID; + loginData.DeviceToken = existingDevice.DeviceToken; + loginData.SessionToken = sessionToken; + loginData.SourceLocation = newLastLoginLocation; + loginData.SourceIP = functionInfo.remote; + loginData.OperationType = 'Login'; + loginData.ServerVersion = config.CCServerVersion; + loginData.APIVersion = receivedObject.APIVersion; + loginData.DeviceHardware = receivedObject.DeviceHardware; + loginData.DeviceSoftware = receivedObject.DeviceSoftware; + loginData.DateTime = timestamp; + + /** + * Store login data. + */ + mainDB.addObject(mainDB.collectionBridgeLogin, loginData, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '194', + info: 'Database offline.' + }); + return; + } + + /** + * Status bits. + * + */ + var AcceptEULA = 0; + if (config.EULAVersion !== existingClient.EULAVersionAccepted) { + AcceptEULA = 1; + } + var clientDetailsSet = 0; + if (utils.bitsAllSet(existingClient.ClientStatus, utils.ClientDetailsMask)) { + clientDetailsSet = 1; + } + + /** + * Assemble strings and objects for response. + */ + var userName = existingClient.DisplayName; + if (userName === '') { + userName = '[New User]'; + } + var toSend = { + SessionToken: sessionToken, + DeviceName: existingDevice.DeviceName, + timeout: utils.sessionTimeout, + PayCodeTimeout: utils.payCodeTimeout, + CallTimeout: config.callTimeout, + MerchantStatus: existingClient.Merchant[0].MerchantStatus, + PollingInterval: utils.pollingInterval, + AcceptEULA: AcceptEULA, + ServerVersion: config.CCServerVersion, + PaymentMin: utils.paymentMin, + PaymentMax: utils.paymentMax, + TipMin: utils.tipMin, + TipMax: utils.tipMax, + TransactionMin: utils.transactionMin, + ClientDetailsSet: clientDetailsSet, + DesyncThreshold: config.HMACDesyncThreshold, + FeatureFlags: existingClient.FeatureFlags + }; + if (existingDevice.PendingHMAC !== '') { + toSend.PendingHMAC = existingDevice.PendingHMAC; + } + + /** + * Update the Client on first login. + */ + if (existingClient.FirstLogin === 1) { + mainDB.updateObject(mainDB.collectionClient, {'ClientName': existingClient.ClientName}, { + $set: { + LastUpdate: timestamp, + FirstLogin: 0 + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '102', + info: 'Database offline.' + }); + return; + } + + /** + * Login success (first login). + */ + toSend.code = '10010'; + toSend.info = 'Login1 first login successful.'; + auth.respond(res, 200, existingDevice, hmacData, functionInfo, + toSend, + 'INFO', + (userName + ' first log in.')); + }); + return; + } + + /** + * Login success (not first login). + */ + toSend.code = '10027'; + toSend.info = 'Login1 successful.'; + auth.respond(res, 200, existingDevice, hmacData, functionInfo, + toSend, + 'INFO', + (userName + ' logged in.')); + }); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/MarkMessage.js b/node_server/ComServe/hJSON/MarkMessage.js new file mode 100644 index 0000000..c0396e2 --- /dev/null +++ b/node_server/ComServe/hJSON/MarkMessage.js @@ -0,0 +1,128 @@ +/** + * @fileOverview Allows a message to be marked as read, unread, etc. + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/messaging_commands/markmessage/} + */ + +/** + * Includes + */ +var Q = require('q'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mongodb = require('mongodb'); + +/** + * This allows a message to be marked as read, unread, etc. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Mark the message as the requested type + * + * Build the query. The limits are: + * - ClientID must be me + * - MessageID must match the _id of the message + * - TimeFilter must be before + */ + var query = { + ClientID: existingClient.ClientID, + _id: mongodb.ObjectID(receivedObject.MessageID), + TimeFilter: {$lte: new Date()} + }; + + /** + * Define the update + */ + var update = { + $currentDate: { + LastUpdate: true + } + }; + + if (receivedObject.Mark === 'Read') { + // Mark as read by setting the read date + update.$currentDate.Read = true; + } else if (receivedObject.Mark === 'Unread') { + // Mark as unread by clearing the read date + update.$set = { + Read: null + }; + } + + /** + * Build the options. + */ + var options = { + upsert: false, + multi: false, + comment: 'MarkMessage' // For profiler logs use + }; + + /** + * Request the object + */ + Q.nfcall( + mainDB.updateObject, + mainDB.collectionMessages, + query, + update, + options, + false + ).then(function(result) { + /** + * Successful query (though it may not have found anything to update) + */ + if (result.result.n === 1) { + /** + * A document was updated, so this is total success + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10072', + info: 'MarkMessage successful' + }, + 'INFO', + ('MarkMessage successful: ' + receivedObject.MessageID)); + } else { + /** + * The request ran, but didn't find any documents + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '484', + info: 'Invalid MessageID.' + }, + 'WARNING', + ('Invalid message ID: ' + receivedObject.MessageID)); + } + }).catch(function() { + /** + * Query failed, most likely from the database + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '483', + info: 'Database offline.' + }); + }); + }); // End auth.validSession callback +}; diff --git a/node_server/ComServe/hJSON/PINReset.js b/node_server/ComServe/hJSON/PINReset.js new file mode 100644 index 0000000..8ef863a --- /dev/null +++ b/node_server/ComServe/hJSON/PINReset.js @@ -0,0 +1,242 @@ +/** + * @fileOverview Node.js PIN Reset Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Resets the device PIN. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/pinreset/} + */ + +/** + * Includes + */ +var templates = require(global.pathPrefix + '../utils/templates.js'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); +var crypto = require('crypto'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + */ +exports.process = function(res, functionInfo, parameters, receivedObject) { + /** + * Valid PIN reset request. Find the e-mail address. + */ + mainDB.findOneObject(mainDB.collectionClient, {ClientName: receivedObject.ClientName}, undefined, false, function(err, existingClient) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '129', + info: 'Database offline.' + }); + return; + } + + /** + * User name not found if null. + */ + if (!existingClient) { + auth.respond(res, 200, null, null, functionInfo, { + code: '130', + info: 'E-mail mismatch.' + }, + 'WARNING', + 'E-mail not found in database.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Check client status. + */ + var currentClientStatus = auth.checkClientStatus(existingClient.ClientStatus); + if (currentClientStatus) { + auth.respond(res, 200, null, null, functionInfo, { + code: currentClientStatus.code.toString(), + info: currentClientStatus.message + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Check the password. + */ + var timestamp = new Date(); + auth.checkClientPassword(receivedObject.Password, existingClient, timestamp, function(err) { + /** + * Check if there was an error processing the password. + */ + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Valid reset request. Find the device again and verify the token. + */ + mainDB.findOneObject(mainDB.collectionDevice, {DeviceNumber: receivedObject.DeviceNumber}, undefined, false, + function(err, existingDevice) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '133', + info: 'Database offline.' + }); + return; + } + + /** + * Device not found. + */ + if (!existingDevice) { + auth.respond(res, 200, null, null, functionInfo, { + code: '134', + info: 'Device number not found.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Check device ID matches. + */ + if (receivedObject.DeviceUuid !== existingDevice.DeviceUuid) { + auth.respond(res, 200, null, null, functionInfo, { + code: '135', + info: 'Invalid device ID.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Check device is associated with e-mail. + */ + if (existingDevice.ClientID !== existingClient.ClientID) { + auth.respond(res, 200, null, null, functionInfo, { + code: '136', + info: 'ClientName mismatch.' + }, + 'WARNING', + 'Device does not belong to Client.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Check device status. + */ + var currentDeviceStatus = auth.checkDeviceStatus(existingDevice.DeviceStatus); + if (currentDeviceStatus) { + auth.respond(res, 200, null, null, functionInfo, { + code: currentDeviceStatus.code.toString(), + info: currentDeviceStatus.message + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * All checks complete. Reset the PIN. + */ + var timestamp = new Date(); + auth.encryptPBKDF2(receivedObject.DeviceAuthorisation, function(err, newSalt, newHash) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '137', + info: 'Encryption error.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Update the database. + */ + var newDeviceAuthorisation = config.pinCryptoVersion + '::' + newHash; + mainDB.updateObject(mainDB.collectionDevice, {DeviceNumber: receivedObject.DeviceNumber}, { + $set: { + DeviceSalt: newSalt, + DeviceAuthorisation: newDeviceAuthorisation, + LoginAttempts: 0, + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '138', + info: 'Database offline.' + }); + return; + } + + /** + * PIN updated. Confirm by e-mail. + */ + var geolocation = ''; + var geoTag = 'Lat/Long'; + if ((receivedObject.Latitude === null) || + (receivedObject.Longitude === null)) { + geoTag = 'No GPS information available.'; + } else { + geolocation = 'http://maps.google.com/maps?q=loc:' + receivedObject.Latitude + + ',' + receivedObject.Longitude; + } + var htmlEmail = templates.render('pin-reset', { + LatLong: geolocation, + GeoTag: geoTag, + DeviceNumber: receivedObject.DeviceNumber + }); + mailer.sendEmail(null, receivedObject.ClientName, 'Bridge PIN Reset', htmlEmail, 'PINReset.process', + function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '139', + info: 'Unable to send e-mail.' + }, + 'ERROR', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * PIN Reset successful. + */ + auth.respond(res, 200, null, null, functionInfo, { + code: '10014', + info: 'PIN reset successful.' + }, + 'INFO', + ('PIN reset successful at Lat/Long [' + receivedObject.Latitude + ',' + + receivedObject.Longitude + '].'), + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + }); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/PayCodeRequest.js b/node_server/ComServe/hJSON/PayCodeRequest.js new file mode 100644 index 0000000..fe5c315 --- /dev/null +++ b/node_server/ComServe/hJSON/PayCodeRequest.js @@ -0,0 +1,352 @@ +/** + * @fileOverview Node.js PayCode Request Handler for Bridge Pay + * @preserve Copyright 2015 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Gets a limited time PayCode that can be given to the merchant. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/paycoderequest/} + */ + +/** + * Includes. + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mongodb = require('mongodb'); +var async = require('async'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check that display names are valid. Otherwise, do not allow the Client to continue. + */ + if (utils.MinDisplayNameLength > existingClient.DisplayName.length) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '472', + info: 'DisplayName is invalid. Please fill out customer details.' + }, + 'WARNING'); + return; + } + if ((utils.MinDisplayNameLength > existingClient.Merchant[0].CompanyAlias.length) && + (existingClient.Merchant[0].MerchantStatus === utils.MerchantStatusActive)) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '473', + info: 'CompanyAlias is invalid. Please fill out Merchant details.' + }, + 'WARNING'); + return; + } + + /** + * Set up paycode to write. + */ + var newPayCode = {}; + newPayCode.DeviceToken = existingDevice.DeviceToken; + newPayCode.SessionToken = receivedObject.SessionToken; + var timestamp = null; + var payCodeLength = 5; + var counter = 5; + var notWritten = true; + var payCodeObject = null; + + /** + * Keep trying to issue a PayCode. + */ + async.doWhilst( + /** + * Keep doing this. + */ + function(callback) { + counter = counter - 1; + newPayCode.PayCode = utils.payCodeGeneration(utils.paycodeString, payCodeLength, 'Bridge'); + timestamp = new Date(); + newPayCode.Creation = new Date(timestamp); + newPayCode.Expiry = new Date(timestamp); + newPayCode.Expiry.setMinutes(newPayCode.Expiry.getMinutes() + utils.payCodeTimeout); + newPayCode.TransactionID = null; + mainDB.addObject(mainDB.collectionPayCode, newPayCode, undefined, true, function(err, payCodeAdded) { + /** + * If the payCodeAdded is returned then the PayCode is unique and has been added to the DB. + */ + if (payCodeAdded) { + notWritten = false; + payCodeObject = payCodeAdded[0]; + } + callback(); + }); + }, + /** + * Until this. + */ + function() { + /** + * Attempt to write 5 paycodes of the same length. + */ + if (counter < 1) { + counter = 5; + payCodeLength = payCodeLength + 1; + } + /** + * Paycode has gotten too long. Return an error. + */ + if (payCodeLength > 12) { + return false; + } else { + return notWritten; + } + }, + /** + * Then do this! + */ + function(err) { + /** + * 5 seconds have passed. + */ + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '157', + info: 'Cannot issue a PayCode.' + }, + 'WARNING', + err.message); + return; + } + + /** + * Check that a unique paycode has been issued. + */ + if (payCodeLength > 12) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '158', + info: 'Cannot generate a unique PayCode.' + }, + 'WARNING'); + return; + } + + /** + * Create the new transaction. + */ + var newTrans = mainDB.blankTransaction(); + newTrans.PayCode = payCodeObject.PayCode; + newTrans.PayCodeID = payCodeObject._id; + newTrans.PayCodeExpiry = payCodeObject.Expiry; + newTrans.CustomerDeviceToken = receivedObject.DeviceToken; + newTrans.CustomerSessionToken = receivedObject.SessionToken; + newTrans.CustomerAccountID = receivedObject.AccountID; + newTrans.CustomerClientID = existingClient.ClientID; + + /** + * Get the account information and fill in transaction. + * + * Need to ignore jshint warnings here: + * 1. Cyclomatic complexity too high - W074. Legacy code. + */ + //jshint -W074 + mainDB.findOneObject(mainDB.collectionAccount, {_id: mongodb.ObjectID(receivedObject.AccountID)}, undefined, false, + function(err, existingCustomerAccount) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '229', + info: 'Database offline.' + }); + return; + } + + /** + * Check that we got an account back. + */ + if (!existingCustomerAccount) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '274', + info: 'Invalid customer AccountID.' + }, + 'WARNING'); + return; + } + + //jshint -W016 + /** + * Check that this is not a deleted account. Uses bitwise comparison. + * + * Need to ignore jshint warnings here: + * 1. Unexpected bitwise comparison. This is deliberately a bitwise comparison. + */ + if (existingCustomerAccount.AccountStatus & utils.AccountDeleted) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '273', + info: 'Deleted customer AccountID.' + }, + 'WARNING'); + return; + } + + /** + * Check that this is not an API generated account (as they + * don't have the encrypted info we need to process a transaction) + * Uses bitwise comparison. + * + * Need to ignore jshint warnings here: + * 1. Unexpected bitwise comparison. This is deliberately a bitwise comparison. + */ + if (existingCustomerAccount.AccountStatus & utils.AccountApiCreated) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '557', + info: 'Unsupported account type.' + }, + 'WARNING'); + return; + } + //jshint +W016 + + /** + * Operational account. Check that there is a valid billing address. + */ + if (existingCustomerAccount.BillingAddress === '') { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '490', + info: 'No valid billing address.' + }, + 'WARNING'); + return; + } + + /** + * Ensure it's a payments account. + */ + if (existingCustomerAccount.PaymentsAccount !== 1) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '295', + info: 'Not a payments account.' + }, + 'WARNING'); + return; + } + + /** + * Fill in account details. + */ + switch (existingCustomerAccount.UserImage) { + case 'Selfie': + newTrans.CustomerDisplayName = existingClient.DisplayName; + newTrans.CustomerImage = existingClient.Selfie; + break; + case 'defaultSelfie': + newTrans.CustomerDisplayName = existingClient.DisplayName; + newTrans.CustomerImage = config.defaultSelfie; + break; + case 'CompanyLogo0': + newTrans.CustomerDisplayName = existingClient.Merchant[0].CompanyAlias; + newTrans.CustomerSubDisplayName = existingClient.Merchant[0].CompanySubName; + newTrans.CustomerImage = existingClient.Merchant[0].CompanyLogo; + if (existingClient.Merchant[0].VATNo) { + newTrans.CustomerVATNo = existingClient.Merchant[0].VATNo; + } + break; + case 'defaultCompanyLogo0': + newTrans.CustomerDisplayName = existingClient.Merchant[0].CompanyAlias; + newTrans.CustomerSubDisplayName = existingClient.Merchant[0].CompanySubName; + newTrans.CustomerImage = config.defaultCompanyLogo0; + if (existingClient.Merchant[0].VATNo) { + newTrans.CustomerVATNo = existingClient.Merchant[0].VATNo; + } + break; + default: + /** + * Error condition. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '230', + info: 'Invalid image details.' + }, + 'ERROR', + ('The UserImage is invalid for AccountID ' + receivedObject.AccountID)); + return; + } + newTrans.StatusInfo = 'Paycode issued. Waiting for merchant...'; + if ((receivedObject.Longitude !== null) && (receivedObject.Latitude !== null)) { + newTrans.CustomerLocation = { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }; + } + newTrans.LastUpdate = new Date(); + + /** + * Write the transaction and get the object ID back. + */ + mainDB.addObject(mainDB.collectionTransaction, newTrans, undefined, false, function(err, transactionAdded) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '159', + info: 'Database offline.' + }); + return; + } + + /** + * Transactions successfully added. Update the PayCode. + */ + var transaction = mongodb.ObjectID(transactionAdded[0]._id).toString(); + mainDB.updateObject( + mainDB.collectionPayCode, + {_id: mongodb.ObjectID(payCodeObject._id)}, + { + $set: { + TransactionID: transaction + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '160', + info: 'Database offline.' + }); + return; + } + + /** + * Return code to user + */ + var timeNow = new Date(); + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10015', + info: 'Paycode issued.', + paycode: newPayCode.PayCode, + creation: newPayCode.Creation, + now: timeNow, + expiry: newPayCode.Expiry, + transactionID: transactionAdded[0]._id + }, + 'INFO', + ('New PayCode ' + newPayCode.PayCode + ' issued.')); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/PostCodeLookup.js b/node_server/ComServe/hJSON/PostCodeLookup.js new file mode 100644 index 0000000..c3ee669 --- /dev/null +++ b/node_server/ComServe/hJSON/PostCodeLookup.js @@ -0,0 +1,61 @@ +/** + * @fileOverview List the addresses that match the given postcode + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/utility_commands/postcode_lookup/} + */ +'use strict'; + +/** + * Includes + */ +var _ = require('lodash'); +var auth = require(global.pathPrefix + 'auth.js'); +var postcodeUtils = require(global.pathPrefix + '../utils/postcodes.js'); + +var utils = require(global.pathPrefix + 'utils.js'); +var apiHelpers = require(global.pathPrefix + '../utils/api_helpers.js'); + +/** + * List addresses for the current postcode. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + const lookupP = postcodeUtils.postcodeLookup(receivedObject.PostCode); + lookupP.then((addresses) => { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, + { + code: '10078', + info: 'Address list returned.', + AddressCount: addresses.length, + Addresses: addresses + }, + 'INFO', + 'PostCodeLookup successful.' + ); + }).catch((error) => { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '531', + info: 'Unable to lookup addresses.' + }); + }); + + }); // End auth.validSession callback +}; diff --git a/node_server/ComServe/hJSON/RedeemPayCode.js b/node_server/ComServe/hJSON/RedeemPayCode.js new file mode 100644 index 0000000..b611044 --- /dev/null +++ b/node_server/ComServe/hJSON/RedeemPayCode.js @@ -0,0 +1,87 @@ +/** + * @fileOverview Node.js Redeem PayCode Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Allows a merchant to add their details to a transaction. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/redeempaycode/} + */ +'use strict'; + +/** + * Includes + */ +const authP = require(global.pathPrefix + 'auth-promises.js'); +const impl = require(global.pathPrefix + '../impl/redeem_paycode.js'); +const Q = require('q'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = async function(res, functionInfo, parameters, receivedObject, hmacData) { + let authDetails = null; + try { + /** + * Validate the session. This function responds directly if there is a problem. + */ + authDetails = await authP.validSession( + res, + receivedObject.DeviceToken, + receivedObject.SessionToken, + functionInfo, + hmacData + ).then((result) => { + return { + existingDevice: result[0], + existingClient: result[1] + }; + }).catch(() => Q.reject()); // Error has been handled in auth.ValidSession + + const response = await impl.redeemPaycodeP( + authDetails.existingClient, + receivedObject); + + authP.respond(res, 200, authDetails.existingDevice, hmacData, functionInfo, + response, + 'INFO', + ('PayCode ' + receivedObject.PayCode + ' redeemed for TransactionID ' + + response.TransactionID + '.')); + } catch (error) { + if (error) { + // + // Check if any of these errors need to be logged + // + let logType = null; + const warnings = [ + '174', + '474', + '475', + '476', + '176', + '179', + '276', + '491', + '275', + '296', + '231' + ]; + if (warnings.indexOf(error.code) !== -1) { + logType = 'WARNING'; + } + authP.respond(res, 200, authDetails.existingDevice, hmacData, functionInfo, + { + code: error.code, + info: error.info + }, + logType); + } + } +}; diff --git a/node_server/ComServe/hJSON/RefundTransaction.js b/node_server/ComServe/hJSON/RefundTransaction.js new file mode 100644 index 0000000..7b80021 --- /dev/null +++ b/node_server/ComServe/hJSON/RefundTransaction.js @@ -0,0 +1,483 @@ +/** + * @fileOverview Node.js Refund Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Refunds the transaction to the customer. This function should only be used if the full transaction is + * being refunded in one go. There is (will) be a separate transaction for partial refunds. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/refundtransaction/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var worldpay = require(global.pathPrefix + 'worldpay.js'); +var mongodb = require('mongodb'); +var async = require('async'); +var _ = require('lodash'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * There are local variables here that must be available throughout the function. + */ + var newinfo = ''; + var locals = {}; + + /** + * Call all database updates in series. + */ + async.series([ + function(callback) { + /** + * Find the transaction to refund. + */ + mainDB.findOneObject(mainDB.collectionTransaction, + {_id: mongodb.ObjectID(receivedObject.TransactionID)}, undefined, false, function(err, existingTransaction) { + if (err) { + callback({ + data: {code: '228', info: 'Database offline.'} + }); + return; + } + + /** + * No transaction in the database. + */ + if (!existingTransaction) { + callback({ + data: {code: '237', info: 'Invalid TransactionID.'}, + logType: 'WARNING' + }); + return; + } + + /** + * Only transactions with a status of 3 can be fully refunded. + * Partial refunds may work on other transactions. + */ + if (existingTransaction.TransactionStatus !== 3) { + if (existingTransaction.TransactionStatus === 4) { + callback({ + data: {code: '235', info: 'Already refunded.'}, + logType: 'WARNING', + altString: 'Transaction already fully refunded.' + }); + return; + } else { + callback({ + data: {code: '236', info: 'Cannot be refunded.'}, + logType: 'WARNING', + altString: 'Cannot be refunded (partial refund may still work).' + }); + return; + } + } + + /** + * Ensure this is the merchant. + */ + if (existingTransaction.MerchantClientID !== existingClient.ClientID) { + callback({ + data: {code: '238', info: 'Invalid ClientName.'}, + logType: 'WARNING', + altString: 'Transaction can only be refunded by the merchant.' + }); + return; + } + + /** + * Store the transaction data. + */ + locals.existingTransaction = existingTransaction; + callback(); + }); + }, + function(callback) { + /** + * Update the transaction. + */ + locals.succeeded = false; + if (locals.existingTransaction.AcquirerName === 'Demo') { + /** + * Refund the Demo transaction. + */ + locals.succeeded = true; + locals.newLastUpdate = new Date(); + locals.newStatusInfo = 'Refunded. Authorisation code ' + utils.timeBasedRandomCode() + '.'; + locals.newTransactionStatus = utils.TransactionStatus.REFUNDED; + locals.newAmountRefunded = locals.existingTransaction.TotalAmount; + callback(); + } else if (locals.existingTransaction.AcquirerName === 'Worldpay') { + /** + * This is a Worldpay transaction refund. + */ + locals.worldpayServiceKey = utils.decryptDataV1(locals.existingTransaction.AcquirerCipher); + if (typeof locals.worldpayServiceKey === 'object') { + callback({ + data: {code: '561', info: 'Error decrypting Worldpay service key.'}, + logType: 'WARNING', + altString: 'Transaction ' + locals.existingTransaction._id.toString() + ': ' + locals.worldpayServiceKey + }); + return; + } + + /** + * Call the refund function. + */ + worldpay.worldpayFunction( + 'POST', + 'orders/' + locals.existingTransaction.SaleReference + '/refund', + locals.worldpayServiceKey, + null, // No additional headers. + {}, + function(err, response) { + if (err) { + console.log('ERR:' + JSON.stringify(err)); + } + if (response) { + console.log('RESPONSE: ' + response); + } + + if (err) { + callback({ + data: {code: '260', info: err.message} + }); + return; + } + + locals.succeeded = true; + locals.newLastUpdate = new Date(); + locals.newStatusInfo = 'Refunded. Worldpay.'; + locals.newTransactionStatus = utils.TransactionStatus.REFUNDED; + locals.newAmountRefunded = locals.existingTransaction.TotalAmount; + + /** + * Success. + */ + callback(); + }); + } else { + /** + * Invalid acquiring bank. + */ + newinfo = 'Invalid acquiring bank (' + locals.existingTransaction.AcquirerName + ').'; + callback({ + data: {code: '242', info: 'Invalid acquirer.'}, + logType: 'ERROR', + altString: newinfo + }); + } + }, + function(callback) { + /** + * Update the transaction. + */ + mainDB.collectionTransaction.findOneAndUpdate({ + _id: mongodb.ObjectID(receivedObject.TransactionID), + MerchantClientID: existingClient.ClientID, + TransactionStatus: utils.TransactionStatus.COMPLETE + }, { + $set: { + AmountRefunded: locals.newAmountRefunded, + StatusInfo: locals.newStatusInfo, + TransactionStatus: locals.newTransactionStatus, + LastUpdate: locals.newLastUpdate + }, + $inc: { + LastVersion: 1 + } + }, { + upsert: false + }, function(err) { + if (err) { + callback({ + data: {code: '260', info: 'Database offline.'} + }); + return; + } + + /** + * Check to see if the payment was a success. + */ + if (!locals.succeeded) { + newinfo = 'Refund failed to ' + locals.existingTransaction.CustomerClientID + '.'; + callback({ + data: {code: '261', info: 'Refund failed.'}, + logType: 'WARNING', + altString: newinfo + }); + return; + } + + /** + * Success. Call back accordingly. + */ + callback(); + }); + }, + function(callback) { + /** + * Set up new history items to reverse the transaction. + */ + locals.newCustomerHist = mainDB.blankTransactionHistory(); + locals.newCustomerHist.TransactionID = receivedObject.TransactionID; + locals.newCustomerHist.TransactionType = 3; + locals.newCustomerHist.AccountID = locals.existingTransaction.CustomerAccountID; + locals.newCustomerHist.ClientID = locals.existingTransaction.CustomerClientID; + locals.newCustomerHist.OtherDisplayName = locals.existingTransaction.MerchantDisplayName; + locals.newCustomerHist.OtherSubDisplayName = locals.existingTransaction.MerchantSubDisplayName; + locals.newCustomerHist.OtherImage = locals.existingTransaction.MerchantImage; + locals.newCustomerHist.TotalAmount = locals.existingTransaction.TotalAmount; + locals.newCustomerHist.SaleTime = locals.newLastUpdate; + locals.newCustomerHist.LastUpdate = locals.newLastUpdate; + if (!_.isUndefined(locals.existingTransaction.MerchantInvoiceNumber)) { + locals.newCustomerHist.MerchantInvoiceNumber = locals.existingTransaction.MerchantInvoiceNumber; + } + locals.newMerchantHist = mainDB.blankTransactionHistory(); + locals.newMerchantHist.TransactionID = receivedObject.TransactionID; + locals.newMerchantHist.TransactionType = 2; + locals.newMerchantHist.AccountID = locals.existingTransaction.MerchantAccountID; + locals.newMerchantHist.ClientID = locals.existingTransaction.MerchantClientID; + locals.newMerchantHist.OtherDisplayName = locals.existingTransaction.CustomerDisplayName; + locals.newMerchantHist.OtherSubDisplayName = locals.existingTransaction.CustomerSubDisplayName; + locals.newMerchantHist.OtherImage = locals.existingTransaction.CustomerImage; + if ((receivedObject.Longitude !== null) && (receivedObject.Latitude !== null)) { + locals.newMerchantHist.MyLocation = { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }; + } + locals.newMerchantHist.TotalAmount = locals.existingTransaction.TotalAmount; + locals.newMerchantHist.SaleTime = locals.newLastUpdate; + locals.newMerchantHist.LastUpdate = locals.newCustomerHist.LastUpdate; + if (!_.isUndefined(locals.existingTransaction.MerchantInvoiceNumber)) { + locals.newMerchantHist.MerchantInvoiceNumber = locals.existingTransaction.MerchantInvoiceNumber; + } + callback(); + }, + function(callback) { + /** + * Call in the customer account details. + */ + mainDB.findOneObject(mainDB.collectionAccount, + {_id: mongodb.ObjectID(locals.existingTransaction.CustomerAccountID)}, undefined, false, + function(err, existingCustomerAccount) { + if (err) { + callback({ + data: {code: '263', info: 'Database offline.'} + }); + return; + } + + /** + * Check that we got an account back. Note that deleted is irrelevant here as we can + * refund into a deleted account. + */ + if (!existingCustomerAccount) { + newinfo = 'Refund succeeded but cannot find customer account for ' + + locals.existingTransaction.CustomerClientID + '.'; + callback({ + data: {code: '264', info: 'Refund successful. No customer account.'}, + logType: 'WARNING', + altString: newinfo + }); + return; + } + + /** + * Store the transaction data. + */ + locals.existingCustomerAccount = existingCustomerAccount; + callback(); + }); + }, + function(callback) { + /** + * Process the transaction - call the merchant account details in. + */ + mainDB.findOneObject(mainDB.collectionAccount, + {_id: mongodb.ObjectID(locals.existingTransaction.MerchantAccountID)}, undefined, false, + function(err, existingMerchantAccount) { + if (err) { + callback({ + data: {code: '263', info: 'Database offline.'} + }); + return; + } + + /** + * Check that we got an account back. Note that deleted is irrelevant here as we + * can refund from a deleted account. + */ + if (!existingMerchantAccount) { + newinfo = 'Refund succeeded but cannot find merchant account for ' + + locals.existingTransaction.MerchantClientID + '.'; + callback({ + data: {code: '265', info: 'Refund successful. No merchant account.'}, + logType: 'WARNING', + altString: newinfo + }); + return; + } + + /** + * Store the transaction data. + */ + locals.existingMerchantAccount = existingMerchantAccount; + callback(); + }); + }, + function(callback) { + /** + * Update the customer account if appropriate. + */ + if (locals.existingCustomerAccount.BalanceAvailable !== 0) { + locals.newBalance = locals.existingCustomerAccount.Balance + locals.existingTransaction.TotalAmount; + locals.newTransactionTotal = locals.existingCustomerAccount.TransactionTotal + + locals.existingTransaction.TotalAmount; + locals.newTotalDeposits = locals.existingCustomerAccount.TotalDeposits + locals.existingTransaction.TotalAmount; + mainDB.updateObject(mainDB.collectionAccount, + {_id: mongodb.ObjectID(locals.existingTransaction.CustomerAccountID)}, { + $set: { + TransactionTotal: locals.newTransactionTotal, + TotalDeposits: locals.newTotalDeposits, + Balance: locals.newBalance, + LastUpdate: locals.newLastUpdate + }, + $inc: { + LastVersion: 1 + } + }, + {upsert: false}, false, function(err) { + if (err) { + callback({ + data: utils.createError('266', 'Database offline') + }); + } else { + callback(); + } + }); + } else { + callback(); + } + }, + function(callback) { + /** + * Update the merchant account. + */ + if (locals.existingMerchantAccount.BalanceAvailable !== 0) { + locals.newBalance = locals.existingMerchantAccount.Balance - locals.existingTransaction.TotalAmount; + locals.newTransactionTotal = locals.existingMerchantAccount.TransactionTotal + + locals.existingTransaction.TotalAmount; + locals.newTotalWithdrawals = locals.existingMerchantAccount.TotalWithdrawals + + locals.existingTransaction.TotalAmount; + mainDB.updateObject(mainDB.collectionAccount, + {_id: mongodb.ObjectID(locals.existingTransaction.MerchantAccountID)}, { + $set: { + TransactionTotal: locals.newTransactionTotal, + TotalWithdrawals: locals.newTotalWithdrawals, + Balance: locals.newBalance, + LastUpdate: locals.newLastUpdate + }, + $inc: { + LastVersion: 1 + } + }, + {upsert: false}, false, function(err) { + if (err) { + callback({ + data: utils.createError('267', 'Database offline') + }); + } else { + callback(); + } + }); + } else { + callback(); + } + }, + function(callback) { + /** + * Add the customer transaction history. + */ + mainDB.addObject(mainDB.collectionTransactionHistory, locals.newCustomerHist, undefined, false, function(err) { + if (err) { + callback({ + data: utils.createError('268', 'Database offline') + }); + } else { + callback(); + } + }); + }, + function(callback) { + /** + * Add the merchant transaction history. + */ + mainDB.addObject(mainDB.collectionTransactionHistory, locals.newMerchantHist, undefined, false, function(err) { + if (err) { + callback({ + data: utils.createError('269', 'Database offline') + }); + } else { + callback(); + } + }); + } + ], + /** + * Callback for information. Note that err can contain 3 elements: data (JSON to return), logType, and altString. + */ + function(err) { + if (err) { + if (err.hasOwnProperty('logType')) { + //jshint -W117 + if (err.hasOwnProperty('altString')) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, err.data, err.logType, err.altString); + } else { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, err.data, err.logType); + } + //jshint +W117 + } else { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, err.data); + } + return; + } + + /** + * Complete success. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10035', + info: 'Refund confirmed.' + }, + 'INFO', + ('Refund confirmed by acquirer to ' + locals.existingTransaction.CustomerClientID + '.')); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Register1.js b/node_server/ComServe/hJSON/Register1.js new file mode 100644 index 0000000..02028df --- /dev/null +++ b/node_server/ComServe/hJSON/Register1.js @@ -0,0 +1,268 @@ +/** + * @fileOverview Node.js Register 1 Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * First registration command. Creates Client and Device database entries. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register1/} + */ + +/** + * Includes + */ +var templates = require(global.pathPrefix + '../utils/templates.js'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var sms = require(global.pathPrefix + 'sms.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + */ +exports.process = function(res, functionInfo, parameters, receivedObject) { + /** + * Local variables. + */ + var timestamp = new Date(); + + /** + * Valid registration request. + */ + log.system( + 'INFO', + 'Registration request received.', + 'Register1.process', + '', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + + /** + * Next verify that the e-mail address has not been used before. + */ + mainDB.findOneObject(mainDB.collectionClient, {ClientName: receivedObject.ClientName}, undefined, false, function(err, existingClient) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '3', + info: 'Database offline.' + }); + return; + } + + /** + * Client info retrieved if present. Check for the device. + */ + mainDB.findOneObject(mainDB.collectionDevice, + {DeviceNumber: receivedObject.DeviceNumber}, undefined, false, function(err, existingDevice) { + /** + * Check for errors. + */ + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '7', + info: 'Database offline.' + }); + return; + } + + /** + * Check that nothing exists in the database. + */ + if (existingDevice || existingClient) { + auth.respond(res, 200, null, null, functionInfo, { + code: '318', + info: 'Email or mobile number already in use. Please log in to recover.' + }, + 'WARNING', + 'Email or mobile number already in use.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * This is a completely new signup. Create client data structure. + */ + var newClient = mainDB.blankClient(); + newClient.ClientName = receivedObject.ClientName; + newClient.DisplayName = ''; + newClient.EMailValidationToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + newClient.EMailValidationTokenExpiry = new Date(timestamp); + newClient.EMailValidationTokenExpiry.setDate(newClient.EMailValidationTokenExpiry.getDate() + 7); // Add a week. + newClient.OperatorName = receivedObject.OperatorName; + newClient.KYC[0].ContactEmail = newClient.ClientName; + newClient.PasswordManagement[0].PasswordExpiry = new Date(timestamp); + newClient.PasswordManagement[0].PasswordExpiry.setDate( + newClient.PasswordManagement[0].PasswordExpiry.getDate() + 365); // Add a year. + newClient.PasswordManagement[0].PasswordLastReset = new Date(timestamp); + newClient.ClientPreferences[0].DefaultAccount = receivedObject.Method; + newClient.LastUpdate = new Date(timestamp); + + /** + * Hash the password. + */ + auth.encryptPBKDF2(receivedObject.Password, function(err, newSalt, newHash) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '413', + info: 'Encryption error.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Store the new salt and hash. + */ + newClient.Password = '2::' + newHash; + newClient.ClientSalt = newSalt; + + /** + * Add the new client object. + */ + mainDB.addObject(mainDB.collectionClient, newClient, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '6', + info: 'Database offline.' + }); + return; + } + + /** + * E -mail successfully added - send the registration e-mail. + */ + mailer.sendWelcomeEmail(newClient, receivedObject.Mode, 'Register1.process', + function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '5', + info: 'Unable to send e-mail.' + }, + 'ERROR', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * E -mail sent. Now set up the new device. + */ + var newDevice = mainDB.blankDevice(); + if (receivedObject.DeviceHardware !== '') { // Add device hardware if sent. + newDevice.DeviceName = 'My ' + receivedObject.DeviceHardware; + } + newDevice.DeviceUuid = receivedObject.DeviceUuid; + newDevice.DeviceHardware = receivedObject.DeviceHardware; + newDevice.DeviceSoftware = receivedObject.DeviceSoftware; + newDevice.DeviceNumber = receivedObject.DeviceNumber; + newDevice.ClientID = newClient.ClientID; + newDevice.RegistrationToken = utils.randomCode(utils.numeric, utils.SMStokenLength); + newDevice.RegistrationTokenExpiry = new Date(timestamp); + newDevice.RegistrationTokenExpiry.setHours(newDevice.RegistrationTokenExpiry.getHours() + + utils.smsTokenDuration); + newDevice.SignupIP = functionInfo.remote; + newDevice.LastUpdate = new Date(timestamp); + newDevice.LastVersion = 1; + + // Generate a unique device token and check it doesn't exist. + newDevice.DeviceToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + mainDB.findOneObject(mainDB.collectionDevice, + {DeviceToken: newDevice.DeviceToken}, undefined, false, function(err, tokenCheck) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '19', + info: 'Database offline.' + }); + return; + } + + /** + * The device token is not unique; log this and cancel registration. + */ + if (tokenCheck !== null) { + auth.respond(res, 200, null, null, functionInfo, { + code: '20', + info: 'System error - token duplication.' + }, + 'ERROR', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * All good. Add the device to the database. + */ + mainDB.addObject(mainDB.collectionDevice, newDevice, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '8', + info: 'Database offline.' + }); + return; + } + + /** + * Send the registration SMS if not in test mode. + */ + sms.sendSMS(receivedObject.Mode, newDevice.DeviceNumber, + ('Your Bridge verification code is ' + newDevice.RegistrationToken), + function(err, smsBalance) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '9', + info: 'SMS send failure.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Success. + */ + if (receivedObject.Mode === 'Test') { + /** + * Test mode. + */ + auth.respond(res, 200, null, null, functionInfo, { + code: '10011', + info: 'Register1 test successful.', + DeviceToken: newDevice.DeviceToken, + EULAVersion: config.EULAVersion + }, + 'INFO', + 'Register 1 test successful (SMS not sent).', + ('RI [' + receivedObject.ClientName + ' (' + + receivedObject.DeviceNumber + ')]')); + } else { + auth.respond(res, 200, null, null, functionInfo, { + code: '10000', + info: 'Register1 successful.', + DeviceToken: newDevice.DeviceToken, + EULAVersion: config.EULAVersion + }, + 'INFO', + ('Registration SMS sent (SMS balance now ' + smsBalance + ').'), + ('RI [' + receivedObject.ClientName + ' (' + + receivedObject.DeviceNumber + ')]')); + } + }); + }); + }); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Register2.js b/node_server/ComServe/hJSON/Register2.js new file mode 100644 index 0000000..e11f719 --- /dev/null +++ b/node_server/ComServe/hJSON/Register2.js @@ -0,0 +1,195 @@ +/** + * @fileOverview Node.js Register2 Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Verifies the mobile device by checking SMS code. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register1/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + */ +exports.process = function(res, functionInfo, parameters, receivedObject) { + /** + * Valid registration request. Find the device again and verify the token. + */ + mainDB.findOneObject(mainDB.collectionDevice, {DeviceNumber: receivedObject.DeviceNumber}, + undefined, false, function(err, existingDevice) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '12', + info: 'Database offline.' + }); + return; + } + + /** + * Check to see if the device was found. + */ + if (!existingDevice) { + auth.respond(res, 200, null, null, functionInfo, { + code: '13', + info: 'Invalid device.' + }, + 'WARNING', + 'Device cannot be found in database.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Register2 only works on unauthorised devices with no flags set. + * Expected use of bitwise. + */ + //jshint -W016 + if ((existingDevice.DeviceStatus !== 0x0) && + ((existingDevice.DeviceStatus & utils.DeviceFullyRegistered) !== utils.DeviceRegister3Mask)) { + auth.respond(res, 200, null, null, functionInfo, { + code: '17', + info: 'This is not a new device.' + }, + 'WARNING', + 'Device is already authorised.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + //jshint +W016 + + /** + * Now check the device token is valid. + */ + if (receivedObject.DeviceToken !== existingDevice.DeviceToken) { + auth.respond(res, 200, null, null, functionInfo, { + code: '14', + info: 'Invalid device token.' + }, + 'WARNING', null, + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Now check the number of attempts that have been made on the token. + */ + if (existingDevice.RegistrationTokenAttempts >= config.maxRegTokenAttempts) { + auth.respond(res, 200, null, null, functionInfo, { + code: '466', + info: 'Too many registration token attempts - delete the device and start again.' + }, + 'WARNING', 'Too many registration token attempts.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Check the registration token expiry. + */ + var timestamp = new Date(); + var expiry = existingDevice.RegistrationTokenExpiry; + if (timestamp > expiry) { + auth.respond(res, 200, null, null, functionInfo, { + code: '15', + info: 'Expired registration token.' + }, + 'WARNING', null, + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Ensure that the code is valid. If not, increment the failed attempts. + */ + if (receivedObject.RegistrationToken !== existingDevice.RegistrationToken) { + mainDB.updateObject(mainDB.collectionDevice, {DeviceNumber: existingDevice.DeviceNumber}, { + $set: {LastUpdate: timestamp}, + $inc: { + LastVersion: 1, + RegistrationTokenAttempts: 1 + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '467', + info: 'Database offline.' + }); + return; + } + + auth.respond(res, 200, null, null, functionInfo, { + code: '16', + info: 'Invalid registration token.' + }, + 'WARNING', null, + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + }); + return; + } + + /** + * Update object and rewrite. + * Write to database. Valid bitwise operation. + */ + //jshint -W016 + var newDeviceStatus = existingDevice.DeviceStatus | utils.DeviceRegister2Mask; + //jshint +W106 + mainDB.updateObject(mainDB.collectionDevice, {DeviceNumber: existingDevice.DeviceNumber}, { + $set: { + LastUpdate: timestamp, + DeviceStatus: newDeviceStatus, + RegistrationToken: '', + RegistrationTokenExpiry: '', + RegistrationTokenAttempts: 0 + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '18', + info: 'Database offline.' + }); + return; + } + + /** + * Successful new registration if equal to zero. + */ + if (existingDevice.DeviceStatus === 0x0) { + auth.respond(res, 200, null, null, functionInfo, { + code: '10001', + info: 'Register2 successful.' + }, + 'INFO', 'Mobile successfully verified.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + // Successful re-registration. + auth.respond(res, 200, null, null, functionInfo, { + code: '10043', + info: 'Register2 re-registration successful.' + }, + 'INFO', 'Mobile successfully re-verified.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Register4.js b/node_server/ComServe/hJSON/Register4.js new file mode 100644 index 0000000..63c8a59 --- /dev/null +++ b/node_server/ComServe/hJSON/Register4.js @@ -0,0 +1,184 @@ +/** + * @fileOverview Node.js Register 4 Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Resends the SMS message to an existing phone. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register4/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var sms = require(global.pathPrefix + 'sms.js'); +var auth = require(global.pathPrefix + 'auth.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + */ +exports.process = function(res, functionInfo, parameters, receivedObject) { + // Resend the SMS message. + /** + * Valid registration request. Find the device to resend the token. + */ + mainDB.findOneObject(mainDB.collectionDevice, {DeviceToken: receivedObject.DeviceToken}, undefined, false, + function(err, existingDevice) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '26', + info: 'Database offline.' + }); + return; + } + + /** + * Ensure there is a device. + */ + if (!existingDevice) { + auth.respond(res, 200, null, null, functionInfo, { + code: '27', + info: 'Invalid device token.' + }, + 'WARNING', + 'Mobile device cannot be matched to token.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * This function only works on unverified devices. + */ + //jshint -W016 + if ((existingDevice.DeviceStatus !== 0x0) && + ((existingDevice.DeviceStatus & utils.DeviceFullyRegistered) !== utils.DeviceRegister3Mask)) { + auth.respond(res, 200, null, null, functionInfo, { + code: '28', + info: 'Device already verified.' + }, + 'WARNING', null, + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + //jshint +W016 + + /** + * Match the phone number. + */ + + if (receivedObject.DeviceNumber !== existingDevice.DeviceNumber) { + auth.respond(res, 200, null, null, functionInfo, { + code: '29', + info: 'DeviceNumber mismatched.' + }, + 'WARNING', + 'Phone number does not match token.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Match the unique device ID. + */ + if (receivedObject.DeviceUuid !== existingDevice.DeviceUuid) { + auth.respond(res, 200, null, null, functionInfo, { + code: '30', + info: 'DeviceUuid mismatched.' + }, + 'WARNING', + 'Unique device ID does not match token.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Check the registration token expiry. + */ + var timestamp = new Date(); + var expiry = existingDevice.RegistrationTokenExpiry; + if (timestamp > expiry) { + auth.respond(res, 200, null, null, functionInfo, { + code: '31', + info: 'Invalid registration token.' + }, + 'WARNING', + 'Registration token is invalid.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Timestamp has at least not expired. Check that the new timestamp is at least 20 seconds. + * older than the last update. + */ + var twentySeconds = existingDevice.LastUpdate; + twentySeconds.setSeconds(twentySeconds.getSeconds() + 20); + if (twentySeconds > timestamp) { + auth.respond(res, 200, null, null, functionInfo, { + code: '33', + info: 'SMS 20 second timeout.' + }, + 'WARNING', + 'Please wait 20 seconds before requesting another SMS.', + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * A reasonable amount of time has been left between clicks. Re-send the registration SMS. + */ + sms.sendSMS(null, existingDevice.DeviceNumber, ('Your Bridge verification code is ' + existingDevice.RegistrationToken), + function(err, smsBalance) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '34', + info: 'SMS send failure.' + }, + 'ERROR', + ('Cannot send SMS. ' + err), + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + return; + } + + /** + * Success. + */ + var newExpiry = new Date(timestamp); + newExpiry.setHours(newExpiry.getHours() + utils.smsTokenDuration); + var newVersion = existingDevice.LastVersion + 1; + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: receivedObject.DeviceToken}, { + $set: { + LastUpdate: timestamp, + RegistrationTokenExpiry: newExpiry, + LastVersion: newVersion + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '32', + info: 'Database offline.' + }); + return; + } + auth.respond(res, 200, null, null, functionInfo, { + code: '10003', + info: 'Register4 successful.' + }, + 'INFO', + ('Registration SMS re-sent. (SMS balance now ' + smsBalance + ').'), + ('RI [' + receivedObject.DeviceNumber + ' (DeviceToken ' + receivedObject.DeviceToken + ')]')); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Register6.js b/node_server/ComServe/hJSON/Register6.js new file mode 100644 index 0000000..ba0123a --- /dev/null +++ b/node_server/ComServe/hJSON/Register6.js @@ -0,0 +1,200 @@ +/** + * @fileOverview Node.js Register 6 Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Resend verification e-mail. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register6/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var auth = require(global.pathPrefix + 'auth.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + */ +exports.process = function(res, functionInfo, parameters, receivedObject) { + /** + * Resend verification e-mail. JSON version. + */ + /** + * Valid registration request. Find the account to resend the token. + */ + mainDB.findOneObject(mainDB.collectionClient, {ClientName: receivedObject.ClientName}, undefined, false, function(err, existingClient) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '85', + info: 'Database offline.' + }); + return; + } + + /** + * Check that the client exists. + */ + if (!existingClient) { + auth.respond(res, 200, null, null, functionInfo, { + code: '86', + info: 'Invalid e-mail address.' + }, + 'WARNING', + 'Cannot find this e-mail address in the database.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * This function only works if the client has never logged in. Check the flag. + * Valid bitwise comparison. + */ + //jshint -W016 + if (existingClient.ClientStatus & utils.ClientEmailVerifiedMask) { + auth.respond(res, 200, null, null, functionInfo, { + code: '87', + info: 'Account already verified.' + }, + 'WARNING', + 'Account already verified (DeviceStatus bit 0x1 set).', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + //jshint +W016 + + /** + * Get the DeviceUuid. + */ + mainDB.findOneObject(mainDB.collectionDevice, {DeviceNumber: receivedObject.DeviceNumber}, undefined, false, + function(err, existingDevice) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '88', + info: 'Database offline.' + }); + return; + } + + /** + * Ensure that the device was found. + */ + if (!existingDevice) { + auth.respond(res, 200, null, null, functionInfo, { + code: '203', + info: 'Mobile phone number not available.' + }, + 'WARNING', + 'Device does not exist.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Ensure DeviceUuid is correct. + */ + if (receivedObject.DeviceUuid !== existingDevice.DeviceUuid) { + auth.respond(res, 200, null, null, functionInfo, { + code: '204', + info: 'Invalid DeviceUuid.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Check the registration token expiry. + */ + var timestamp = new Date(); + var expiry = existingClient.EMailValidationTokenExpiry; + if (timestamp > expiry) { + auth.respond(res, 200, null, null, functionInfo, { + code: '89', + info: 'Invalid registration token.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Timestamp has at least not expired. Check that the new timestamp is at least 20 + * seconds older than the last update. + */ + var twentySeconds = existingClient.LastUpdate; + twentySeconds.setSeconds(twentySeconds.getSeconds() + 20); + if (twentySeconds > timestamp) { + auth.respond(res, 200, null, null, functionInfo, { + code: '90', + info: 'E-mail 20 second timeout.' + }, + 'WARNING', + 'Wait 20 seconds before requesting another e-mail.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Update database. + */ + var newExpiry = new Date(timestamp); + newExpiry.setDate(newExpiry.getDate() + 7); // Add a week. + var newVersion = existingClient.LastVersion + 1; + mainDB.updateObject(mainDB.collectionClient, {ClientName: receivedObject.ClientName}, { + $set: { + LastUpdate: timestamp, + EMailValidationTokenExpiry: newExpiry, + LastVersion: newVersion + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '92', + info: 'Database offline.' + }); + return; + } + + /** + * Resend the welcome / address confirmation e-mail. + */ + mailer.sendWelcomeEmail(existingClient, '', 'Register6.process', function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '91', + info: 'E-mail send failure.' + }, + 'ERROR', + 'Unable to send e-mail.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Success! + */ + auth.respond(res, 200, null, null, functionInfo, { + code: '10009', + info: 'Register6 successful.' + }, + 'INFO', + 'Registration e-mail re-sent.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Register7.js b/node_server/ComServe/hJSON/Register7.js new file mode 100644 index 0000000..2ca0409 --- /dev/null +++ b/node_server/ComServe/hJSON/Register7.js @@ -0,0 +1,255 @@ +/** + * @fileOverview Node.js Register 7 Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Deletes an account. Add the "Mode"="ForceDelete" parameter to force deletion. HTML version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register7/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + */ +exports.process = function(res, functionInfo, parameters) { + /** + * Valid delete request. Find the e-mail address first. + */ + mainDB.findOneObject(mainDB.collectionClient, {ClientName: parameters.ClientName}, undefined, false, function(err, existingClient) { + if (err) { + auth.respondHTML(res, 200, functionInfo, '53', 'templates/undef_database_offline.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '53', + ipInfo: functionInfo.remote + }); + return; + } + + /** + * Check that the client exists. + */ + if (!existingClient) { + auth.respondHTML(res, 200, functionInfo, '54', 'templates/54_email_not_found.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '54', + ClientName: parameters.ClientName, + ipInfo: functionInfo.remote + }, + 'WARNING', + 'E-mail does not exist.', + ('RI [' + parameters.ClientName + ' (' + parameters.DeviceNumber + ')]')); + return; + } + + /** + * Client does exist - pull the device. + */ + mainDB.findOneObject(mainDB.collectionDevice, {DeviceNumber: parameters.DeviceNumber}, undefined, false, + function(err, existingDevice) { + if (err) { + auth.respondHTML(res, 200, functionInfo, '55', 'templates/undef_database_offline.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '55', + ipInfo: functionInfo.remote + }); + return; + } + + /** + * Check we got a device match. + */ + if (!existingDevice) { + auth.respondHTML(res, 200, functionInfo, '56', 'templates/56_mobile_number_not_found.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '56', + DeviceNumber: parameters.DeviceNumber, + ipInfo: functionInfo.remote + }, + 'WARNING', + 'Device does not exist.', + ('RI [' + parameters.ClientName + ' (' + parameters.DeviceNumber + ')]')); + return; + } + + /** + * OK, both exist. Run checks to ensure it can be deleted. + */ + if (existingClient.ClientID !== existingDevice.ClientID) { + auth.respondHTML(res, 200, functionInfo, '57', 'templates/57_association_error.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '57', + ClientName: parameters.ClientName, + DeviceNumber: parameters.DeviceNumber, + ipInfo: functionInfo.remote + }, + 'WARNING', + 'Device is not registered to this e-mail address.', + ('RI [' + parameters.ClientName + ' (' + parameters.DeviceNumber + ')]')); + return; + } + + /** + * Check registration status. If FirstLogin !== 1, disallow the deletion. + */ + if (existingClient.FirstLogin !== 1) { + /** + * There is one exception - if this is the Dev server and ForceDelete is sent. + */ + if ((('Mode' in parameters) && (parameters.Mode === 'ForceDelete')) && config.isDevEnv) { + /** + * Deletion will be forced as this is the Dev server. + */ + log.system( + 'INFO', + 'Forced deletion initialised (Mode = ForceDelete).', + 'Register7.process', + '', + ('RI [' + parameters.ClientName + ' (' + parameters.DeviceNumber + ')]'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + } else { + auth.respondHTML(res, 200, functionInfo, '58', 'templates/58_fully_registered.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '58', + ipInfo: functionInfo.remote + }, + 'WARNING', + 'This is a fully registered account.', + ('RI [' + parameters.ClientName + ' (' + parameters.DeviceNumber + ')]')); + return; + } + } + + /** + * Clean up the existing client before archiving it + * - Move _id to OldClientID: Note the clash with the + * client identifier ClientID which must be retained. + * - Remove the existing Password and ClientSalt. + * - Update the LastUpdate time. + */ + var clientId = existingClient._id; + existingClient.OldClientID = clientId.toString(); + delete existingClient._id; + existingClient.Password = ''; + existingClient.ClientSalt = ''; + existingClient.LastUpdate = new Date(); + + /** + * Back up Client to the Archive. + */ + mainDB.addObject(mainDB.collectionClientArchive, existingClient, undefined, false, function(err) { + if (err) { + auth.respondHTML(res, 200, functionInfo, '146', 'templates/undef_database_offline.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '146', + ipInfo: functionInfo.remote + }); + return; + } + + /** + * Now remove the Client. + */ + mainDB.removeObject(mainDB.collectionClient, {_id: clientId}, undefined, false, function(err) { + if (err) { + auth.respondHTML(res, 200, functionInfo, '59', 'templates/undef_database_offline.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '59', + ipInfo: functionInfo.remote + }); + return; + } + + /** + * Report that the Client is out of database. + */ + log.system( + 'INFO', + 'Client has been successfully removed.', + 'Register7.process', + '10005', + ('RI [' + parameters.ClientName + ' (' + parameters.DeviceNumber + ')]'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + + /** + * Back up existing Device. + */ + var deviceId = existingDevice._id; + existingDevice.DeviceIndex = existingDevice._id.toString(); + delete existingDevice._id; + existingDevice.DeviceAuthorisation = ''; + existingDevice.DeviceSalt = ''; + existingDevice.PendingHMAC = ''; + existingDevice.CurrentHMAC = ''; + existingDevice.LastUpdate = new Date(); + + /** + * Back up existing Device. + */ + mainDB.addObject(mainDB.collectionDeviceArchive, existingDevice, undefined, false, function(err) { + // Check for errors. + if (err) { + auth.respondHTML(res, 200, functionInfo, '145', 'templates/undef_database_offline.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '145', + ipInfo: functionInfo.remote + }); + return; + } + + /** + * Device added to archive. Delete from active devices. + */ + mainDB.removeObject(mainDB.collectionDevice, {_id: deviceId}, undefined, false, function(err) { + if (err) { + auth.respondHTML(res, 200, functionInfo, '60', 'templates/undef_database_offline.pug', { + pretty: true, + title: 'Comcarde Bridge', + errornumber: '60', + ipInfo: functionInfo.remote + }); + return; + } + + /** + * Device removed from database. + */ + auth.respondHTML(res, 200, functionInfo, '10005', 'templates/10005_reg_deleted.pug', { + pretty: true, + title: 'Comcarde Bridge', + ClientName: parameters.ClientName, + DeviceNumber: parameters.DeviceNumber, + ipInfo: functionInfo.remote + }, + 'INFO', + 'Device has been successfully removed.', + ('RI [' + parameters.ClientName + ' (' + parameters.DeviceNumber + ')]')); + }); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/Register8.js b/node_server/ComServe/hJSON/Register8.js new file mode 100644 index 0000000..2b46de6 --- /dev/null +++ b/node_server/ComServe/hJSON/Register8.js @@ -0,0 +1,227 @@ +/** + * @fileOverview Node.js Register 8 Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Deletes an account. Add the "Mode"="ForceDelete" parameter to force deletion. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register8/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + */ +exports.process = function(res, functionInfo, parameters, receivedObject) { + /** + * Valid registration request. Find the e-mail address first. + */ + mainDB.findOneObject(mainDB.collectionClient, {ClientName: receivedObject.ClientName}, undefined, false, + function(err, existingClient) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '61', + info: 'Database offline.' + }); + return; + } + + /** + * Valid delete request. Find the e-mail address first. + */ + if (!existingClient) { + auth.respond(res, 200, null, null, functionInfo, { + code: '62', + info: 'E-mail address does not exist.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * OK, so the client does exist. Check for the device. + */ + mainDB.findOneObject(mainDB.collectionDevice, {DeviceNumber: receivedObject.DeviceNumber}, undefined, false, + function(err, existingDevice) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '63', + info: 'Database offline.' + }); + return; + } + + /** + * Find the device second. + */ + if (!existingDevice) { + auth.respond(res, 200, null, null, functionInfo, { + code: '64', + info: 'Mobile phone number not in use.' + }, + 'WARNING', + 'Device does not exist.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * OK, both exist. Run checks to ensure they can be deleted. + * Firstly, check that they are not wrongly linked. + */ + if (existingClient.ClientID !== existingDevice.ClientID) { + auth.respond(res, 200, null, null, functionInfo, { + code: '65', + info: 'Device not registered to this e-mail address.' + }, + 'WARNING', null, + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + + /** + * Check registration status. If FirstLogin !== 1, disallow the deletion. + */ + if (existingClient.FirstLogin !== 1) { + /** + * There is one exception - if this is the Dev server and ForceDelete is sent. + */ + if ((('Mode' in receivedObject) && (receivedObject.Mode === 'ForceDelete')) && config.isDevEnv) { + /** + * Deletion will be forced as this is the Dev server. + */ + log.system( + 'INFO', + 'Forced deletion initialised (Mode = ForceDelete).', + 'Register8.process', + '', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + } else { + auth.respond(res, 200, null, null, functionInfo, { + code: '66', + info: 'Account fully registered.' + }, + 'WARNING', + 'This is a fully registered account.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + return; + } + } + + /** + * Clean up the existing client before archiving it + * - Move _id to ClientID + * - Remove the existing Password and ClientSalt. + * - Update the LastUpdate time + */ + var clientId = existingClient._id; + existingClient.OldClientID = clientId.toString(); + delete existingClient._id; + existingClient.Password = ''; + existingClient.ClientSalt = ''; + existingClient.LastUpdate = new Date(); + + /** + * Back up Client Archive. + */ + mainDB.addObject(mainDB.collectionClientArchive, existingClient, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '147', + info: 'Database offline.' + }); + return; + } + + /** + * The account can be safely deleted. + */ + mainDB.removeObject(mainDB.collectionClient, {_id: clientId}, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '67', + info: 'Database offline.' + }); + return; + } + + /** + * Client is out of database. + */ + log.system( + 'INFO', + 'Client has been successfully removed.', + 'Register8.process', + '10006', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]'), + (functionInfo.remote + ' (' + functionInfo.port + ')')); + + /** + * Back up existing Device. + */ + var deviceId = existingDevice._id; + existingDevice.DeviceIndex = existingDevice._id.toString(); + delete existingDevice._id; + existingDevice.DeviceAuthorisation = ''; + existingDevice.DeviceSalt = ''; + existingDevice.PendingHMAC = ''; + existingDevice.CurrentHMAC = ''; + existingDevice.LastUpdate = new Date(); + + /** + * Archive it. + */ + mainDB.addObject(mainDB.collectionDeviceArchive, existingDevice, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '148', + info: 'Database offline.' + }); + return; + } + + /** + * Remove device. + */ + mainDB.removeObject(mainDB.collectionDevice, {_id: deviceId}, undefined, false, function(err) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '68', + info: 'Database offline.' + }); + return; + } + + /** + * Device is out of database + */ + auth.respond(res, 200, null, null, functionInfo, { + code: '10006', + info: 'Client and Device removed.' + }, + 'INFO', + 'Device has been successfully removed.', + ('RI [' + receivedObject.ClientName + ' (' + receivedObject.DeviceNumber + ')]')); + }); + }); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/RejectInvoice.js b/node_server/ComServe/hJSON/RejectInvoice.js new file mode 100644 index 0000000..088b3f8 --- /dev/null +++ b/node_server/ComServe/hJSON/RejectInvoice.js @@ -0,0 +1,184 @@ +/** + * @fileOverview Rejects an invoice (with optional comment) + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/merchant_commands/reject_invoice/} + */ + +/* + * Includes + */ +var mongodb = require('mongodb'); +var templates = require(global.pathPrefix + '../utils/templates.js'); +var Q = require('q'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var formattingUtils = require(global.pathPrefix + '../utils/formatting.js'); + +/** + * Rejects an invoice that the client doesn't believe is correct. An optional + * comment can be provided. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in invoice body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Build the query for the invoice we are rejecting. The limits are: + * - ClientID of the invoice must match the current client + * - _id must match the given InvoiceID + * - Invoice must be in Pending state + */ + var query = { + _id: mongodb.ObjectID(receivedObject.InvoiceID), + CustomerClientID: existingClient.ClientID, + TransactionStatus: utils.TransactionStatus.PENDING_INVOICE + }; + + /** + * Define the projection + */ + var projection = { + _id: 1, + /* Values required for formatting the notification email */ + MerchantClientID: 1, + CustomerDisplayName: 1, + MerchantInvoiceNumber: 1, + CustomerComment: 1 + }; + + var options = { + projection: projection, + upsert: false, + returnOriginal: false, // Return the updated document + comment: 'RejectInvoice' // For profiler logs use + }; + + /* + * Update values + */ + var update = { + $set: { + TransactionStatus: utils.TransactionStatus.REJECTED_INVOICE + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + if (receivedObject.Comment) { + update.$set.CustomerComment = receivedObject.Comment; + } else { + update.$set.CustomerComment = ''; + } + + /** + * Find the invoice + */ + mainDB.collectionTransaction.findOneAndUpdate( + query, + update, + options, + function(err, response) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '517', + info: 'Database offline.' + }); + return; + } + + /** + * Couldn't find anything to update + */ + if (!response.value) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '518', + info: 'Invalid InvoiceID or not in Pending status.' + }, + 'WARNING'); + return; + } + + /** + * Notify the merchant that the customer has queried the invoice. + * This doesn't affect the success of querying the invoice. + */ + notifyRejectedInvoice( + response.value.MerchantClientID, + response.value + ); + + /** + * Report the success of querying the invoice + */ + auth.respond( + res, + 200, + existingDevice, + hmacData, + functionInfo, { + code: '10077', + info: 'Invoice rejected.' + }, + 'INFO', + 'Invoice rejected (InvoiceID ' + receivedObject.InvoiceID + ').' + ); + + }); + }); +}; + +/** + * Notifies the merchant that an existing invoice has been queried by the customer. + * + * @param {string} merchantID - the merchant's ID (to find their email address) + * @param {object} invoice - the invoice (for adding info to the email) + * + * @returns {Promise} - a promise for the result of notifying the customer + */ +function notifyRejectedInvoice(merchantID, invoice) { + var reviewUrl = formattingUtils.formatPortalUrl( + 'business/invoices/' + invoice._id.toString() + '/update' + ); + + /** + * Render the html for the email + */ + var htmlEmail = templates.render('invoice-queried', { + customer: invoice.CustomerDisplayName, + number: invoice.MerchantInvoiceNumber.InvoiceNumber, + comment: invoice.CustomerComment, + reviewUrl: reviewUrl + }); + + return Q.nfcall( + mailer.sendEmailByID, + '', // Mode ('Test' to just log, anything else to send) + merchantID, // Destination + 'Queried Invoice', // Subject + htmlEmail, + 'notifyRejectedInvoice' + ); +} + diff --git a/node_server/ComServe/hJSON/ReportImage.js b/node_server/ComServe/hJSON/ReportImage.js new file mode 100644 index 0000000..b79f581 --- /dev/null +++ b/node_server/ComServe/hJSON/ReportImage.js @@ -0,0 +1,140 @@ +/** + * @fileOverview Node.js Report Image Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Marks an image as offensive in the database. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/reportimage/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var config = require(global.configFile); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check whether client is allowed to report images. + */ + //jshint -W016 + if (existingClient.ClientStatus & utils.ClientCantReport) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '224', + info: 'Reporting disabled.' + }, + 'WARNING', + 'Client not allowed to flag images.'); + return; + } + //jshint +W016 + + /** + * Check this is not one of the Client's own images. + */ + if ((receivedObject.ImageRef === config.defaultSelfie) || + (receivedObject.ImageRef === config.defaultCompanyLogo0) || + (receivedObject.ImageRef === existingClient.Selfie) || + (receivedObject.ImageRef === existingClient.Merchant[0].CompanyLogo)) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '326', + info: 'Client cannot report default or their own images.' + }, + 'WARNING'); + return; + } + + /** + * Find the image file to return. + */ + mainDB.findOneObject(mainDB.collectionImages, + {_id: mongodb.ObjectID(receivedObject.ImageRef)}, + undefined, + false, + function(err, existingImage) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '327', + info: 'Database offline.' + }); + return; + } + + /** + * Check to see if the image exists. + */ + if (!existingImage) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '328', + info: 'Invalid ImageRef.' + }, + 'WARNING', + 'Client reported an invalid ImageRef.'); + return; + } + + /** + * Image exists. Report again requires a counter increment. + */ + var newLastUpdate = new Date(); + var newImageReported = existingImage.ImageReported; + if (newImageReported < 9999) { + newImageReported = newImageReported + 1; + } + + /** + * Update the database. + */ + mainDB.updateObject(mainDB.collectionImages, {_id: mongodb.ObjectID(receivedObject.ImageRef)}, { + $set: { + ImageReported: newImageReported, + LastUpdate: newLastUpdate + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '288', + info: 'Database offline.' + }); + return; + } + + /** + * Tell the user that the image has been marked as reported. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10034', + info: 'Image reported.' + }, + 'INFO', + ('Image reported (ID ' + receivedObject.ImageRef + ').')); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/ResumeDevice.js b/node_server/ComServe/hJSON/ResumeDevice.js new file mode 100644 index 0000000..9b75f72 --- /dev/null +++ b/node_server/ComServe/hJSON/ResumeDevice.js @@ -0,0 +1,140 @@ +/** + * @fileOverview Node.js Resume Device Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Resumes a particular device if it belongs to the current client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/resumedevice/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Local variables + */ + var timestamp = new Date(); + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check the current password. + */ + auth.checkClientPassword(receivedObject.Password, existingClient, timestamp, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING'); + return; + } + + /** + * Find the device. + */ + mainDB.findOneObject(mainDB.collectionDevice, + { + _id: mongodb.ObjectId(receivedObject.DeviceIndex), + ClientID: existingClient.ClientID + }, + undefined, + false, + function(err, device) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '433', + info: 'Database offline.' + }); + return; + } + + /** + * No hits from database. + */ + if (device === null) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '434', + info: 'Invalid device or device does not belong to client.' + }, + 'WARNING'); + return; + } + + /** + * Device has not been suspended. + * Valid bitwise operation. + */ + //jshint -W016 + if (!(device.DeviceStatus & utils.DeviceSuspendedMask)) { + //jshint +W016 + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '435', + info: 'Device has not been suspended.' + }, + 'WARNING'); + return; + } + + /** + * The device can be resumed. + * Correct use of bitwise manipulation. + */ + //jshint -W016 + mainDB.updateObject(mainDB.collectionDevice, {_id: mongodb.ObjectId(receivedObject.DeviceIndex)}, { + $bit: { + DeviceStatus: {and: ~utils.DeviceSuspendedMask} + }, + $set: { + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + //jshint +W016 + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '436', + info: 'Database offline.' + }); + return; + } + + /** + * Success. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10063', + info: 'Device Resumed.' + }, + 'INFO'); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/RotateHMAC.js b/node_server/ComServe/hJSON/RotateHMAC.js new file mode 100644 index 0000000..7008312 --- /dev/null +++ b/node_server/ComServe/hJSON/RotateHMAC.js @@ -0,0 +1,98 @@ +/** + * @fileOverview Node.js RotateHMAC Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Confirms the new HMAC has been received, accepted and stored by the device. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/rotatehmac/} + */ + +/** + * Includes + */ +var auth = require(global.pathPrefix + 'auth.js'); +var log = require(global.pathPrefix + 'log.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * If there is no pending HMAC, do nothing. + */ + if (existingDevice.PendingHMAC === '') { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '442', + info: 'No pending HMAC.' + }, + 'WARNING'); + return; + } + + /** + * Check the call is signed with the Pending HMAC. + */ + auth.checkHMAC(existingDevice, hmacData, 'RotateHMAC.process', function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: err.code.toString(), + info: err.message + }, + 'WARNING'); + return; + } + + /** + * Rotate the HMAC. Clear HMAC attempts as a bad HMAC may have corrupted the CurrentHMAC. + */ + var timestamp = new Date(); + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: existingDevice.DeviceToken}, + { + $set: { + PendingHMAC: '', + CurrentHMAC: existingDevice.PendingHMAC, + HMACAttempts: 0, + LastUpdate: timestamp + } + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '443', + info: 'Database offline.' + }); + return; + } + + /** + * HMAC successfully updated. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10065', + info: 'HMAC rotation successful.' + }, + 'INFO'); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/SendReport.js b/node_server/ComServe/hJSON/SendReport.js new file mode 100644 index 0000000..1822d8b --- /dev/null +++ b/node_server/ComServe/hJSON/SendReport.js @@ -0,0 +1,148 @@ +/** + * @fileOverview Node.js Send Report Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Returns a list of transactions on an account for the referenced user. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/misc_commands/sendreport/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var moment = require('moment'); +var config = require(global.configFile); + +/** + * Module variables. + */ +var bankFees = 29; // RBS charges 29p. +var incomingIP = '62.232.80.210'; + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + */ +exports.process = function(res, functionInfo, parameters) { + /** + * Operations are only allowed in certain situations or from certain locations. + */ + if ((functionInfo.remote !== incomingIP) || (!config.isDevEnv)) { + auth.respond(res, 200, null, null, functionInfo, { + code: '317', + info: 'Invalid IP or not Dev server.' + }, + 'WARNING'); + return; + } + + /** + * Find the relevant transactions. + * Note that the cyclomatic complexity is known to be high. + */ + //jshint -W074 + mainDB.collectionTransaction.find( + { + MerchantClientID: parameters.MerchantClientID, + SaleTime: {'$gte': new Date(parameters.DateGTE), '$lt': new Date(parameters.DateLT)} + }, + { + _id: 1, + CustomerDisplayName: 1, + SaleTime: 1, + RequestAmount: 1, + TipAmount: 1, + SaleReference: 1, + SaleAuthCode: 1, + TransactionStatus: 1 + } + ).sort({SaleTime: -1}).toArray(function(err, items) { + if (err) { + auth.respond(res, 200, null, null, functionInfo, { + code: '316', + info: 'Database offline.' + }); + return; + } + + /** + * Respond with a list of items. + */ + if (items) { + /** + * Filter the account information. + */ + var total = 0; + var tipTotal = 0; + var counter = 0; + var timestamp = new Date(); + var csvFile = 'BRIDGE activity report for ' + parameters.MerchantClientName + utils.CarriageReturn; + csvFile += 'Generated:' + timestamp + utils.CarriageReturn; + csvFile += 'Period: GTE ' + parameters.DateGTE + ', LT ' + parameters.DateLT + + utils.CarriageReturn + utils.CarriageReturn; + csvFile += 'Date\t\tTime\t\tCustomer\t\tAmount\tTip\tSale Reference\tAuth\tTransactionID' + utils.CarriageReturn; + + /** + * Go through each item and return a subset of information. + */ + while (counter < items.length) { + /** + * Select card information. + */ + if ((items[counter].TransactionStatus === 3) || (items[counter].TransactionStatus === 4)) { + csvFile += moment(items[counter].SaleTime).format('DD-MM-YYYY') + '\t'; + csvFile += moment(items[counter].SaleTime).format('HH:mm:ss') + '\t'; + csvFile += items[counter].CustomerDisplayName + '\t\t'; + if (items[counter].TransactionStatus === 3) { + csvFile += (items[counter].RequestAmount / 100).toFixed(2) + '\t'; + } else { + csvFile += (items[counter].RequestAmount / 100).toFixed(2) + 'R\t'; + } + if (items[counter].TransactionStatus === 3) { + csvFile += (items[counter].TipAmount / 100).toFixed(2) + '\t'; + } else { + csvFile += (items[counter].TipAmount / 100).toFixed(2) + 'R\t'; + } + csvFile += items[counter].SaleReference + '\t'; + csvFile += items[counter].SaleAuthCode + '\t'; + csvFile += items[counter]._id + utils.CarriageReturn; + if (items[counter].TransactionStatus === 3) { + total += items[counter].RequestAmount; + tipTotal += items[counter].TipAmount; + } + } + counter++; // Always increment the counter. + } + csvFile += utils.CarriageReturn + '-----------------------' + utils.CarriageReturn + 'Total Amount:\t' + + (total / 100).toFixed(2) + utils.CarriageReturn; + csvFile += 'Total Tip:\t' + (tipTotal / 100).toFixed(2) + utils.CarriageReturn; + csvFile += 'Bank Fees:\t' + (bankFees / 100).toFixed(2) + utils.CarriageReturn; + csvFile += '-----------------------' + utils.CarriageReturn + 'Total (GBP):\t' + + ((total + tipTotal + bankFees) / 100).toFixed(2) + utils.CarriageReturn + '-----------------------'; + + /** + * Send the information. + */ + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end(csvFile); + log.system( + 'INFO', + 'Report sent.', + 'SendReport.process', + '', + 'UU', + (functionInfo.remote + ' (' + functionInfo.port + ')')); + } + }); + //jshint +W074 +}; diff --git a/node_server/ComServe/hJSON/SetAccountAddress.js b/node_server/ComServe/hJSON/SetAccountAddress.js new file mode 100644 index 0000000..e460b9f --- /dev/null +++ b/node_server/ComServe/hJSON/SetAccountAddress.js @@ -0,0 +1,176 @@ +/** + * @fileOverview Node.js Set Account Address Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Allows the user to change the address associated with an account. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/setaccountaddress/} + */ + +/** + * Includes + */ +var mongodb = require('mongodb'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Find the address. + */ + mainDB.findOneObject(mainDB.collectionAddresses, + { + _id: mongodb.ObjectID(receivedObject.AddressID), + ClientID: existingClient.ClientID + }, + { + _id: 1 + }, + false, + function(err, existingAddress) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '392', + info: 'Database offline.' + }); + return; + } + + /** + * Ensure that an address was found. + */ + if (!existingAddress) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '393', + info: 'Cannot find address.' + }, + 'WARNING'); + return; + } + + /** + * Find the account. + */ + mainDB.findOneObject(mainDB.collectionAccount, + { + _id: mongodb.ObjectID(receivedObject.AccountID), + ClientID: existingClient.ClientID + }, + { + _id: 1, + AccountStatus: 1, + BillingAddress: 1 + }, + false, + function(err, existingAccount) { + /** + * Check for errors. + */ + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '394', + info: 'Database offline.' + }); + return; + } + + /** + * Ensure that an account was found. + */ + if (!existingAccount) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '395', + info: 'Cannot find account.' + }, + 'WARNING'); + return; + } + + /** + * Check for deleted accounts. + */ + //jshint -W016 + if (existingAccount.AccountStatus & utils.AccountDeleted) { + //jshint +W016 + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '396', + info: 'Cannot change a deleted account.' + }, + 'WARNING'); + return; + } + + /** + * Different response if there is no change. + */ + if (existingAccount.BillingAddress === receivedObject.AddressID) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10056', + info: 'BillingAddress already set to this AddressID.' + }, + 'INFO'); + return; + } + + /** + * Update the account with the new Address. + */ + var timestamp = new Date(); + mainDB.updateObject(mainDB.collectionAccount, + { + _id: mongodb.ObjectID(receivedObject.AccountID), + ClientID: existingClient.ClientID + }, + { + $set: { + BillingAddress: receivedObject.AddressID, + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '397', + info: 'Database offline.' + }); + return; + } + + /** + * Account address successfully set. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10055', + info: 'Account address set.' + }, + 'INFO'); + }); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/SetClientDetails.js b/node_server/ComServe/hJSON/SetClientDetails.js new file mode 100644 index 0000000..79a9c06 --- /dev/null +++ b/node_server/ComServe/hJSON/SetClientDetails.js @@ -0,0 +1,106 @@ +/** + * @fileOverview Node.js Set Client Details Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Sets the client's KYC details in the Client record. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/setclientdetails/} + */ +'use strict'; + +/** + * Includes + */ +var httpStatus = require('http-status-codes'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var references = require(global.pathPrefix + '../utils/references.js'); +var responsesUtils = require(global.pathPrefix + '../utils/responses.js'); +var diligence = require(global.pathPrefix + '../utils/diligence/diligence.js'); +var clientUtils = require(global.pathPrefix + '../utils/client/client.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + // + // Get the current user's email from the session + // + var setP = clientUtils.setKyc(existingClient, receivedObject); + + setP.then((result) => { + // + // We may have warnings to respond with + // + const responses = [ + [ + clientUtils.SETKYC_RESPONSES.OK, + httpStatus.OK, 10059, 'Client details set.' + ], + [ + clientUtils.SETKYC_RESPONSES.WARNING_REFER, + httpStatus.OK, 10079, 'Additional information required.' + ], + [ + clientUtils.SETKYC_RESPONSES.WARNING_INTERNAL_CHECKS, + httpStatus.OK, 10080, 'Additional internal checks required.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respondAuth( + res, result, existingDevice, hmacData, functionInfo, 'INFO' + ); + }).catch((error) => { + const responses = [ + [ + 'MongoError', + httpStatus.OK, 423, 'Database Offline', true + ], + [ + references.ERRORS.INVALID_ADDRESS, + httpStatus.OK, 532, 'Invalid Address', true + ], + [ + diligence.ERRORS.VERIFICATION_FAILED, + httpStatus.OK, 533, 'Unable to verify id', true + ], + [ + clientUtils.SETKYC_ERRORS.DOB_MISMATCH, + httpStatus.OK, 426, 'Date of birth mismatch' + ], + [ + clientUtils.SETKYC_ERRORS.UPDATE_FAILED, + httpStatus.OK, 534, 'Client not found during update' + ], + [ + clientUtils.SETKYC_ERRORS.INVALID_PARAMETERS, + httpStatus.OK, 535, 'Invalid paramters' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respondAuth( + res, error, existingDevice, hmacData, functionInfo, 'INFO' + ); + }).done(); + }); +}; diff --git a/node_server/ComServe/hJSON/SetDefaultAccount.js b/node_server/ComServe/hJSON/SetDefaultAccount.js new file mode 100644 index 0000000..56e2874 --- /dev/null +++ b/node_server/ComServe/hJSON/SetDefaultAccount.js @@ -0,0 +1,165 @@ +/** + * @fileOverview Node.js Set Default Account Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Sets the default account for the user. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/setdefaultaccount/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Local variables + */ + var timestamp = new Date(); + + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Check to see if this is a clear default account. + */ + if (receivedObject.AccountID === '') { + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: receivedObject.DeviceToken}, { + $set: { + DefaultAccount: '', + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '314', + info: 'Database offline.' + }); + return; + } + + /** + * Success! There are different return codes for set or clear. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10046', + info: 'Default account cleared.' + }, + 'INFO'); + }); + return; + } + + /** + * Set request. Get the account from the database. + */ + mainDB.findOneObject(mainDB.collectionAccount, + { + _id: mongodb.ObjectID(receivedObject.AccountID), + ClientID: existingClient.ClientID + }, + undefined, + false, + function(err, existingAccount) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '301', + info: 'Database offline.' + }); + return; + } + + /** + * No hits from database. + */ + if (existingAccount === null) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '302', + info: 'No account match.' + }, + 'WARNING', + 'Invalid AccountID or Account does not belong to client.'); + return; + } + + /** + * Check to ensure that the account has not already been deleted. + * Valid bitwise comparison. + */ + //jshint -W016 + if (existingAccount.AccountStatus & utils.AccountDeleted) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '304', + info: 'Account has been deleted.' + }, + 'WARNING'); + return; + } + + if (existingAccount.AccountStatus & utils.AccountApiCreated) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '556', + info: 'Unsupported account type.' + }, + 'WARNING'); + return; + } + //jshint +W016 + + /** + * Update the default account. + */ + mainDB.updateObject(mainDB.collectionDevice, {DeviceToken: receivedObject.DeviceToken}, { + $set: { + DefaultAccount: receivedObject.AccountID, + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '305', + info: 'Database offline.' + }); + return; + } + + /** + * Success! + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10045', + info: 'Account successfully set as default.' + }, + 'INFO', + ('AccountID ' + receivedObject.AccountID + ' set as default.')); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/SetDeviceName.js b/node_server/ComServe/hJSON/SetDeviceName.js new file mode 100644 index 0000000..0c03c93 --- /dev/null +++ b/node_server/ComServe/hJSON/SetDeviceName.js @@ -0,0 +1,92 @@ +/** + * @fileOverview Node.js Set Device Name Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Sets the name of a particular device if it belongs to the current client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/setdevicename/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Local variables + */ + var timestamp = new Date(); + + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Update the device. + */ + mainDB.updateObject(mainDB.collectionDevice, + { + _id: mongodb.ObjectId(receivedObject.DeviceIndex), + ClientID: existingClient.ClientID + }, + { + $set: { + DeviceName: receivedObject.DeviceName, + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err, result) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '439', + info: 'Database offline.' + }); + return; + } + + /** + * No hits from database if 0 or less. + */ + if (result.result.n <= 0) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '440', + info: 'Invalid device or device does not belong to client.' + }, + 'WARNING'); + return; + } + + /** + * Success. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10064', + info: 'Device name set.' + }, + 'INFO'); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/SuspendDevice.js b/node_server/ComServe/hJSON/SuspendDevice.js new file mode 100644 index 0000000..658dafa --- /dev/null +++ b/node_server/ComServe/hJSON/SuspendDevice.js @@ -0,0 +1,136 @@ +/** + * @fileOverview Node.js Suspend Device Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Suspends a particular device if it belongs to the current client. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/suspenddevice/} + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var mongodb = require('mongodb'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {function} process + * @param {!object} res - Response object for returning information. + * @param {!object} functionInfo - detail on the calling function {!name, !remote, !port} + * @param {?object} parameters - Input parameters posted on link. + * @param {?object} receivedObject - Input parameters in message body. + * @param {?object} hmacData - HMAC information from incoming packet. + */ +exports.process = function(res, functionInfo, parameters, receivedObject, hmacData) { + /** + * Local variables + */ + var timestamp = new Date(); + + /** + * Validate the session. This function responds directly if there is a problem. + */ + auth.validSession(res, receivedObject.DeviceToken, receivedObject.SessionToken, functionInfo, hmacData, + function(err, existingDevice, existingClient) { + if (err) { + return; + } + + /** + * Find the device. + */ + mainDB.findOneObject(mainDB.collectionDevice, + { + _id: mongodb.ObjectId(receivedObject.DeviceIndex), + ClientID: existingClient.ClientID + }, + undefined, + false, + function(err, device) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '428', + info: 'Database offline.' + }); + return; + } + + /** + * No hits from database. + */ + if (device === null) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '429', + info: 'Invalid device or device does not belong to client.' + }, + 'WARNING'); + return; + } + + /** + * Cannot suspend the device that is being used. + */ + if (device.DeviceNumber === existingDevice.DeviceNumber) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '430', + info: 'Cannot suspend the device currently in use.' + }, + 'WARNING'); + return; + } + + /** + * Device will not be suspended as it is already suspended. + * Valid bitwise operation. + */ + //jshint -W016 + if (device.DeviceStatus & utils.DeviceSuspendedMask) { + //jshint +W016 + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '437', + info: 'Device has already been suspended.' + }, + 'WARNING'); + return; + } + + /** + * The device can be suspended. + */ + mainDB.updateObject(mainDB.collectionDevice, {_id: mongodb.ObjectId(receivedObject.DeviceIndex)}, { + $bit: { + DeviceStatus: {or: utils.DeviceSuspendedMask} + }, + $set: { + LastUpdate: timestamp + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, function(err) { + if (err) { + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '431', + info: 'Database offline.' + }); + return; + } + + /** + * Success. + */ + auth.respond(res, 200, existingDevice, hmacData, functionInfo, { + code: '10062', + info: 'Device suspended.' + }, + 'INFO'); + }); + }); + }); +}; diff --git a/node_server/ComServe/hJSON/specs/ElevateSession.spec.js b/node_server/ComServe/hJSON/specs/ElevateSession.spec.js new file mode 100644 index 0000000..d41597d --- /dev/null +++ b/node_server/ComServe/hJSON/specs/ElevateSession.spec.js @@ -0,0 +1,322 @@ +/** + * Unit testing file for ElevateSession command + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 5] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../../tools/test/testGlobals.js'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const elevateSession = rewire('../ElevateSession.js'); +const authStub = elevateSession.__get__('authP'); + +const expect = chai.expect; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +/** + * Define a sample Client and Device object to return + */ +const DEVICE_TOKEN = 'abc123'; +const SESSION_TOKEN = 'def456'; +const CLIENT_EMAIL = 'a@example.com'; +const PASSWORD = '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8'; // "password" + +const FAKE_CLIENT = { + ClientName: CLIENT_EMAIL +}; +const FAKE_DEVICE = {}; + +/** + * Values for testing failures + */ +const NOT_DEVICE_TOKEN = 'ghi789'; +const NOT_SESSION_TOKEN = 'jkl012'; +const NOT_CLIENT_EMAIL = 'not-a@example.com'; +const NOT_PASSWORD = '05f721989a4f70756a3b8387767affd11c776a0c863f1a22410855e606753321'; // "notpassword" + +/** + * Define some fake parameters + */ +const fakeFunctionInfo = {}; +const fakeParameters = {}; +const fakeHmacData = []; + +const res = sinon.spy(); + +describe('ElevateSession', () => { + describe('with valid parameters', () => { + let callP; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(() => { + sinon.stub(authStub, 'validSession').resolves([FAKE_DEVICE, FAKE_CLIENT]); + sinon.stub(authStub, 'checkClientPassword').resolves(''); + sinon.stub(authStub, 'respond').returns(); + + const testData = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + ClientName: CLIENT_EMAIL, + Password: PASSWORD + }; + + callP = elevateSession.process(res, fakeFunctionInfo, fakeParameters, testData, fakeHmacData); + }); + + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + authStub.validSession.restore(); + authStub.checkClientPassword.restore(); + authStub.respond.restore(); + }); + + it('runs', () => { + return expect(callP).to.eventually.be.fulfilled; + }); + + it('validates the session', () => { + return callP.then(() => + expect(authStub.validSession).to.have.been + .calledOnce + .calledWith(res, DEVICE_TOKEN, SESSION_TOKEN) + ); + }); + + it('checks the client password', () => { + return callP.then(() => + expect(authStub.checkClientPassword).to.have.been + .calledOnce + .calledWith(PASSWORD, FAKE_CLIENT) + ); + }); + + it('responds', () => { + return callP.then(() => + expect(authStub.respond).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match({ + code: '10079', + info: 'Session Elevated.' + }) + ) + ); + }); + }); + + describe('without valid session', () => { + let callP; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(() => { + sinon.stub(authStub, 'validSession').rejects(); + sinon.stub(authStub, 'checkClientPassword').resolves(''); + sinon.stub(authStub, 'respond').returns(); + + const testData = { + DeviceToken: NOT_DEVICE_TOKEN, + SessionToken: NOT_SESSION_TOKEN, + ClientName: CLIENT_EMAIL, + Password: PASSWORD + }; + + callP = elevateSession.process(res, fakeFunctionInfo, fakeParameters, testData, fakeHmacData); + }); + + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + authStub.validSession.restore(); + authStub.checkClientPassword.restore(); + authStub.respond.restore(); + }); + + it('runs', () => { + return expect(callP).to.eventually.be.fulfilled; + }); + + it('validates the session', () => { + return callP.then(() => + expect(authStub.validSession).to.have.been + .calledOnce + .calledWith(res, NOT_DEVICE_TOKEN, NOT_SESSION_TOKEN) + ); + }); + + it('fails before checking password', () => { + return callP.then(() => + expect(authStub.checkClientPassword).to.not.have.been.called + ); + }); + + it('does NOT respond (validSession deals with the response)', () => { + return callP.then(() => + expect(authStub.respond).to.have.not.been.called + ); + }); + }); + + describe('with wrong email address', () => { + let callP; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(() => { + sinon.stub(authStub, 'validSession').resolves([FAKE_DEVICE, FAKE_CLIENT]); + sinon.stub(authStub, 'checkClientPassword').resolves(''); + sinon.stub(authStub, 'respond').returns(); + + const testData = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + ClientName: NOT_CLIENT_EMAIL, + Password: PASSWORD + }; + + callP = elevateSession.process(res, fakeFunctionInfo, fakeParameters, testData, fakeHmacData); + }); + + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + authStub.validSession.restore(); + authStub.checkClientPassword.restore(); + authStub.respond.restore(); + }); + + it('runs', () => { + return expect(callP).to.eventually.be.fulfilled; + }); + + it('validates the session', () => { + return callP.then(() => + expect(authStub.validSession).to.have.been + .calledOnce + .calledWith(res, DEVICE_TOKEN, SESSION_TOKEN) + ); + }); + + it('fails before checking password', () => { + return callP.then(() => + expect(authStub.checkClientPassword).to.not.have.been.called + ); + }); + + it('responds with correct error', () => { + return callP.then(() => + expect(authStub.respond).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match({ + code: '559', + info: 'Invalid ClientName.' + }) + ) + ); + }); + }); + + describe('with wrong email password', () => { + let callP; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(() => { + sinon.stub(authStub, 'validSession').resolves([FAKE_DEVICE, FAKE_CLIENT]); + sinon.stub(authStub, 'checkClientPassword').rejects({ + code: 123, + message: 'One of many password errors' + }); + sinon.stub(authStub, 'respond').returns(); + + const testData = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + ClientName: CLIENT_EMAIL, + Password: NOT_PASSWORD + }; + + callP = elevateSession.process(res, fakeFunctionInfo, fakeParameters, testData, fakeHmacData); + }); + + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + authStub.validSession.restore(); + authStub.checkClientPassword.restore(); + authStub.respond.restore(); + }); + + it('runs', () => { + return expect(callP).to.eventually.be.fulfilled; + }); + + it('validates the session', () => { + return callP.then(() => + expect(authStub.validSession).to.have.been + .calledOnce + .calledWith(res, DEVICE_TOKEN, SESSION_TOKEN) + ); + }); + + it('checks the client password', () => { + return callP.then(() => + expect(authStub.checkClientPassword).to.have.been + .calledOnce + .calledWith(NOT_PASSWORD, FAKE_CLIENT) + ); + }); + + it('responds with correct error', () => { + return callP.then(() => + expect(authStub.respond).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match({ + code: '123', + info: 'One of many password errors' + }) + ) + ); + }); + }); +}); diff --git a/node_server/ComServe/hJSON/specs/RedeemPaycode.spec.js b/node_server/ComServe/hJSON/specs/RedeemPaycode.spec.js new file mode 100644 index 0000000..9e591c5 --- /dev/null +++ b/node_server/ComServe/hJSON/specs/RedeemPaycode.spec.js @@ -0,0 +1,184 @@ +/** + * Unit testing file for RedeemPaycode command + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 5] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../../tools/test/testGlobals.js'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const redeemPaycodeClass = rewire('../RedeemPayCode.js'); +const authStub = redeemPaycodeClass.__get__('authP'); +const implStub = redeemPaycodeClass.__get__('impl'); + +const expect = chai.expect; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +/** + * Define a sample Client and Device object to return + */ +const DEVICE_TOKEN = 'abc123'; +const SESSION_TOKEN = 'def456'; +const CLIENT_EMAIL = 'a@example.com'; +const ACCOUNTID = '58e3a700f50f21000166b890'; +const PAYCODE = 'KCT9A'; + +const MERCHANTCOMMENT = 'You were served today by Stuey.'; +const REQUESTAMOUNT = 399; +const REQUESTTIP = 1; +const LATITUDE = 0.0; +const LONGITUDE = 0.0; + +const FAKE_CLIENT = { + ClientName: CLIENT_EMAIL +}; +const FAKE_DEVICE = {}; + +const SuccessReturn = { + code: '10020', + info: 'PayCode redeemed.', + TransactionID: '23N2O5D9' +}; +const FailureReturn = { + code: '474', + info: 'DisplayName is invalid. Please fill out customer details.' +}; + +/** + * Define some fake parameters + */ +const fakeFunctionInfo = {}; +const fakeParameters = {}; +const fakeHmacData = []; + +const res = sinon.spy(); + +describe('RedeemPaycode', () => { + describe('with valid parameters', () => { + let callP; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(() => { + sinon.stub(authStub, 'validSession').resolves([FAKE_DEVICE, FAKE_CLIENT]); + sinon.stub(implStub, 'redeemPaycodeP').resolves(SuccessReturn); + sinon.stub(authStub, 'respond').returns(); + + const testData = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + AccountID: ACCOUNTID, + PayCode: PAYCODE, + MerchantComment: MERCHANTCOMMENT, + RequestAmount: REQUESTAMOUNT, + RequestTip: REQUESTTIP, + Latitude: LATITUDE, + Longitude: LONGITUDE + }; + + callP = redeemPaycodeClass.process(res, fakeFunctionInfo, fakeParameters, testData, fakeHmacData); + }); + + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + authStub.validSession.restore(); + authStub.respond.restore(); + implStub.redeemPaycodeP.restore(); + }); + it('runs', () => { + return expect(callP).to.eventually.be.fulfilled; + }); + it('validates the session', () => { + return callP.then(expect(authStub.validSession).to.have.been + .calledOnce + .calledWith(res, DEVICE_TOKEN, SESSION_TOKEN)); + }); + + it('responds', () => { + return callP.then(() => + expect(authStub.respond).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match({ + code: '10020', + info: 'PayCode redeemed.' + }) + )); + }); + }); + + describe('reponds with WARNING', () => { + let callP; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(() => { + sinon.stub(authStub, 'validSession').resolves([FAKE_DEVICE, FAKE_CLIENT]); + sinon.stub(implStub, 'redeemPaycodeP').rejects(FailureReturn); + sinon.stub(authStub, 'respond').returns(); + + const testData = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + AccountID: ACCOUNTID, + PayCode: PAYCODE, + MerchantComment: MERCHANTCOMMENT, + RequestAmount: REQUESTAMOUNT, + RequestTip: REQUESTTIP, + Latitude: LATITUDE, + Longitude: LONGITUDE + }; + + callP = redeemPaycodeClass.process(res, fakeFunctionInfo, fakeParameters, testData, fakeHmacData); + }); + + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + authStub.validSession.restore(); + authStub.respond.restore(); + implStub.redeemPaycodeP.restore(); + }); + it('runs', () => { + return expect(callP).to.eventually.be.fulfilled; + }); + it('responds', () => { + return callP.then(() => + expect(authStub.respond).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match({ + code: '474', + info: 'DisplayName is invalid. Please fill out customer details.' + }), + sinon.match('WARNING') + )); + }); + }); +}); diff --git a/node_server/ComServe/log.js b/node_server/ComServe/log.js new file mode 100644 index 0000000..7ebff30 --- /dev/null +++ b/node_server/ComServe/log.js @@ -0,0 +1,68 @@ +/** + * @fileOverview Node.js Console Logging Functionality for Bridge Pay + * @preserve Copyright 2015 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ + +/** + * Includes + */ +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var config = require(global.configFile); + +/** + * Object variables + */ +exports.verbose = 1; // Used to start/stop console logging. Set to 0 on release versions. + +/** + * Writes info plus time stamp to both console and database. and log file. + * Entry type defines the message type: e.g. Info, Warning, Error, WWW etc. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/systemlog/} + */ +exports.system = function(entryClass, entryInfo, entryFunction, entryCode, entryUser, entrySource) { + /** + * @type {function} system + * @param {!string} entryClass - The type of event - e.g. INFO, ERROR, WARNING. + * @param {!string} entryInfo - A free text string giving more information on whatever happened. + * @param {!string} entryFunction - The function or module that generated the entry. + * @param {?string} entryCode - An error code if appropriate (blank otherwise). + * @param {?string} entryUser - The originating person 'client@me.com (0771823450)'. + * @param {!string} entrySource - The source that caused the event e.g. '86.118.232.2 (HTTPS:443)'. + */ + /** + * Create the log data structure. + */ + var logData = {}; + logData.DateTime = new Date(); + logData.ServerID = config.CCServerName + ' (VIP ' + config.CCServerIP + ')'; // Note the Virtual IP can be used by more than one box. + logData.Class = entryClass; + logData.Function = entryFunction; + logData.Code = entryCode; + logData.Info = entryInfo; + logData.User = entryUser; + logData.Source = entrySource; + + /** + * If the system is in verbose mode, output to the console. + */ + if (exports.verbose) { + var consoleOutput = '[' + logData.DateTime.toISOString() + ' ' + logData.ServerID + '] '; + consoleOutput += logData.Class + ' (' + logData.Function; + if (entryCode !== '') { + consoleOutput += (', ' + entryCode); + } + consoleOutput += ') from ' + logData.User + ' at ' + logData.Source + ': ' + logData.Info; + console.log(consoleOutput); + } + + /** + * Add the object to the system log. + */ + if (mainDB.dbOnline) { + mainDB.addObject(mainDB.collectionSystemLog, logData, undefined, false, null); + } +}; diff --git a/node_server/ComServe/mailer-promises.js b/node_server/ComServe/mailer-promises.js new file mode 100644 index 0000000..650bf79 --- /dev/null +++ b/node_server/ComServe/mailer-promises.js @@ -0,0 +1,15 @@ +/** + * @file This file wraps the functions in mailer.js with promises for simpler + * use in promises and async/await + */ + +const Q = require('q'); +const mailer = require('./mailer.js'); + +module.exports = { + sendEmail: (...args) => Q.nfapply(mailer.sendEmail, args), + sendEmailByID: (...args) => Q.nfapply(mailer.sendEmailByID, args), + sendWelcomeEmail: (...args) => Q.nfapply(mailer.sendWelcomeEmail, args), + sendEmailChangedEmails: (...args) => Q.nfapply(mailer.sendEmailChangedEmails, args), + sendEmailRevertedEmails: (...args) => Q.nfapply(mailer.sendEmailRevertedEmails, args) +}; diff --git a/node_server/ComServe/mailer.js b/node_server/ComServe/mailer.js new file mode 100644 index 0000000..a01bcf6 --- /dev/null +++ b/node_server/ComServe/mailer.js @@ -0,0 +1,341 @@ +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Comcarde Node.js Mailer Functionality +// Provides -Bridge- pay functionality. +// Copyright 2014 Comcarde +// Written by Keith Symington +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Includes +var nodemailer = require('nodemailer'); +var log = require(global.pathPrefix + 'log.js'); +var Q = require('q'); +var templates = require(global.pathPrefix + '../utils/templates.js'); +var formattingUtils = require(global.pathPrefix + '../utils/formatting.js'); +var references = require(global.pathPrefix + '../utils/references.js'); +var debug = require('debug')('utils:mailer'); + +/** + * Set up the exports + */ +module.exports = { + sendEmail: sendEmail, + sendEmailByID: sendEmailByID, + sendWelcomeEmail: sendWelcomeEmail, + sendEmailChangedEmails: sendEmailChangedEmails, + sendEmailRevertedEmails: sendEmailRevertedEmails +}; + +/** + * Define the email transport options + */ +const TRANSPORTER = nodemailer.createTransport({ + service: 'Gmail', + auth: { + user: 'admin@comcarde.com', + pass: 'xnasacgwvfvskvlj' + } +}); + +/** + * Generic function to send emails + * + * @param {String} mode - 'Test' to not send + * @param {String} destination - email address to send to + * @param {String} subject - email subject + * @param {String} htmlBody - email body in prepared HTML + * @param {String} caller - name of the caller for logging purposes + * @param {function} [next] - callback for success (if needed) + */ +function sendEmail(mode, destination, subject, htmlBody, caller, next) { + // Create the log data structure. + var mailOptions = { + from: 'admin@comcarde.com', // sender address + to: destination, // list of receivers + subject: subject, // Subject line + html: htmlBody // html body + }; + + if (mode !== 'Test') { + // Sent the e-mail using the transporter. + TRANSPORTER.sendMail(mailOptions, function(err, info) { + if (err) { + log.system( + 'CRITICAL', + ('Unable to send e-mail. ' + err), + caller, + '', + 'System', + '127.0.0.1'); + if (next) { + next(err); + } + } else { + log.system( + 'INFO', + ('E-mail sent to ' + destination + ' (' + info.response + ').'), + caller, + '', + 'System', + '127.0.0.1'); + if (next) { + next(null); + } + } + }); + } else { + // Simply call back in test mode. + log.system( + 'WARNING', + ('E-mail test to ' + destination + ' (e-mail not sent).'), + caller, + '', + 'System', + '127.0.0.1'); + if (next) { + next(null); + } + } +} + +/** + * Generic function to send emails to a client identified by an ID. + * This will lookup the client's email address from the database and then pass + * it on to the basic function for completion. + * + * @param {String} mode - 'Test' to not send + * @param {String} clientID - ID of the client to send email to + * @param {String} subject - email subject + * @param {String} htmlBody - email body in prepared HTML + * @param {String} caller - name of the caller for logging purposes + * @param {function} [next] - callback for success (if needed) + */ +function sendEmailByID(mode, clientID, subject, htmlBody, caller, next) { + references.getEmailAddress(clientID) + .then(function(email) { + sendEmail(mode, email, subject, htmlBody, caller, next); + }) + .catch(function(err) { + log.system( + 'CRITICAL', + ('Unable to find client to send e-mail to. ' + err), + caller, + '', + 'System', + '127.0.0.1'); + if (next) { + next(err); + } + }); +} + +/** + * Sends the welcome email to the client. + * + * @param {Client} newClient - The newly added client + * @param {String} mode - 'test' to not actually send the email + * @param {String} caller - The name of the caller for logging purposed + * @param {Function} next - Callback function for callers who don't want promises + * + * @returns {Promise} - A promise for the result of sending the email + */ +function sendWelcomeEmail(newClient, mode, caller, next) { + // + // Get the email parameters + // + var token = newClient.EMailValidationToken; + var email = newClient.ClientName; + var query = { + code: token, + email: email + }; + var confirmUrl = formattingUtils.formatPortalUrl('confirmemail-link', query); + var denyEmailUrl = formattingUtils.formatPortalUrl('denyemail-link', query); + + debug('- send welcome email to: [%s], token [%s] ', email, token); + + // + // Render the email + // + var htmlEmail = templates.render( + 'bridge-welcome', + { + emailValidationCode: token, + confirmEmailUrl: confirmUrl, + denyEmailUrl: denyEmailUrl + }); + var subject = 'Welcome to Bridge'; + + // + // Pass it to the mailer to send (wrapped in a Q.nfcall to turn it into + // a promise). + // When the promise completes, call any callback defined + // + return Q.nfcall(sendEmail, mode, email, subject, htmlEmail, caller) + .then(function() { + // Success has no return values + if (next) { + next(); + } + }) + .catch(function(err) { + if (next) { + next(err); + } + return Q.reject(err); // Pass on the error + }); +} + +/** + * Sends the emails related to an email change. + * This sends an email to the old address so they can revert if neccessary, + * and an address to the new address to confirm the email + * + * @param {String} oldEmail - the old email address being changed away from + * @param {String} newEmail - the new email address being changed to + * @param {Object} revertToken - The token to use to revert the email change + * @param {String} revertToken.token - the token + * @param {Object} confirmToken - The token to use to confirm the new email address + * @param {String} confirmToken.token - the token + * @param {String} mode - 'test' to not actually send the email + * @param {String} caller - The name of the caller for logging purposed + * @param {Function} next - Callback function for callers who don't want promises + * + * @returns {Promise} - A promise for the result of sending the email + */ +function sendEmailChangedEmails(oldEmail, newEmail, revertToken, confirmToken, mode, caller, next) { + // + // Build the urls. + // + var revertQuery = { + code: revertToken.token, + email: oldEmail + }; + var confirmQuery = { + code: confirmToken.token, + email: newEmail + }; + var baseRevertUrl = formattingUtils.formatPortalUrl('revert-changed-email-link'); + var revertUrl = formattingUtils.formatPortalUrl('revert-changed-email-link', revertQuery); + + var confirmChangeUrl = formattingUtils.formatPortalUrl('confirmemail-link', confirmQuery); + + debug('- send email changed emails to: [%s] -> [%s] ', oldEmail, newEmail); + + // + // Render the emails + // + var revertEmailBody = templates.render( + 'email-changed-old', + { + oldEmail: oldEmail, + newEmail: newEmail, + revertEmailChangeUrl: revertUrl, + revertEmailChangeBaseUrl: baseRevertUrl, + revertValidationCode: revertQuery.code + }); + var revertEmailSubject = 'Important: Email changed on Bridge Account'; + + var confirmEmailBody = templates.render( + 'email-changed-new', + { + confirmChangedEmailUrl: confirmChangeUrl, + emailValidationCode: confirmQuery.code + }); + var confirmEmailSubject = 'Please confirm your email address'; + + // + // Pass it to the mailer to send (wrapped in a Q.nfcall to turn it into + // a promise). + // When the promise completes, call any callback defined + // + var sendRevertEmail = Q.nfcall( + sendEmail, + mode, + oldEmail, + revertEmailSubject, + revertEmailBody, + caller + ); + var sendConfirmEmail = Q.nfcall( + sendEmail, + mode, + newEmail, + confirmEmailSubject, + confirmEmailBody, + caller + ); + return Q.all([sendRevertEmail, sendConfirmEmail]) + .then(function() { + // Success has no return values + if (next) { + next(); + } + }) + .catch(function(err) { + if (next) { + next(err); + } + return Q.reject(err); // Pass on the error + }); +} + +/** + * Sends the emails related to an email change. + * This sends an email to the old address so they can revert if neccessary, + * and an address to the new address to confirm the email + * + * @param {String} revertToEmail - the old email address being reverted back to + * @param {String} revertFromEmail - the new email address being reverted from + * @param {String} mode - 'test' to not actually send the email + * @param {String} caller - The name of the caller for logging purposed + * @param {Function} next - Callback function for callers who don't want promises + * + * @returns {Promise} - A promise for the result of sending the email + */ +function sendEmailRevertedEmails(revertToEmail, revertFromEmail, mode, caller, next) { + debug('- send email reverted emails to: [%s] -> [%s] ', revertToEmail, revertFromEmail); + + // + // Render the emails + // + var revertToEmailBody = templates.render('email-reverted-to', {}); + var revertToEmailSubject = 'Email change reverted on Bridge Account'; + + var revertFromEmailBody = templates.render('email-reverted-from', {}); + var revertFromEmailSubject = 'Email change reverted on Bridge Account'; + + // + // Pass it to the mailer to send (wrapped in a Q.nfcall to turn it into + // a promise). + // When the promise completes, call any callback defined + // + var sendRevertToEmail = Q.nfcall( + sendEmail, + mode, + revertToEmail, + revertToEmailSubject, + revertToEmailBody, + caller + ); + var sendRevertFromEmail = Q.nfcall( + sendEmail, + mode, + revertFromEmail, + revertFromEmailSubject, + revertFromEmailBody, + caller + ); + return Q.all([sendRevertToEmail, sendRevertFromEmail]) + .then(function() { + // Success has no return values + if (next) { + next(); + } + }) + .catch(function(err) { + if (next) { + next(err); + } + return Q.reject(err); // Pass on the error + }); +} diff --git a/node_server/ComServe/mainDB-promises.js b/node_server/ComServe/mainDB-promises.js new file mode 100644 index 0000000..ae55650 --- /dev/null +++ b/node_server/ComServe/mainDB-promises.js @@ -0,0 +1,76 @@ +/** + * @file This file wraps the functions in mainDB.js with promises for simpler + * use in promises and async/await + */ + +const Q = require('q'); +const httpStatus = require('http-status-codes'); + +// +// We MUST require maindDB with the exact same path as where it is initialised or we +// end up with a different instance of it where the collections have not been initialised. +// +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); + +module.exports = { + findOneObject: (...args) => Q.nfapply(mainDB.findOneObject, args), + addObject: (...args) => Q.nfapply(mainDB.addObject, args), + addMany: (...args) => Q.nfapply(mainDB.addMany, args), + updateObject: (...args) => Q.nfapply(mainDB.updateObject, args), + removeObject: (...args) => Q.nfapply(mainDB.removeObject, args), + addObjectPWithCode: (...args) => withCode(module.exports.addObject, args), + findOneObjectPWithCode: (...args) => withCode(module.exports.findOneObject, args), + updateObjectPWithCode: (...args) => withCode(module.exports.updateObject, args), + removeObjectPWithCode: (...args) => withCode(module.exports.removeObject, args), + + updateObjectPCheckObjectUpdated: (...args) => checkObjectUpdated(mainDB.updateObject, args), + + /** + * Share the mainDB file for easy access to the collections + */ + mainDB +}; + +/** + * Wrapper functions that allows for specific error handling or promise functions + * + * @type {Function} withCode + * @param {!Function} action - function that this function has wrapped around + * @param {!Array} args - Options for the insert command. Use 'undefined' if there are none. + */ +function withCode(action, args) { + const code = args[args.length - 1]; + const params = args.slice(0, args.length - 1); + + return action(...params).catch(() => + Q.reject(utils.createError(code, 'Database offline.', httpStatus.BAD_GATEWAY))); +} + +/** + * Specific Wrapper for mongoDB update + * Handles general mongoDB errors and if the update fails to update any objects. + * + * @type {Function} checkObjectUpdated + * @param {!Function} action - function that this function has wrapped around + * @param {!Array} args - Options for the insert command. Use 'undefined' if there are none. + */ +function checkObjectUpdated(action, args) { + const code = args[args.length - 1]; + const params = args.slice(0, args.length - 1); + return Q.nfcall(action, ...params) + .then((result) => { + if (result.result.nModified === 1) { + return Q.resolve(result); + } else { + return Q.reject(utils.createError(code, 'Failed to update object', httpStatus.CONFLICT)); + } + }) + .catch((error) => { + if (error.code && error.httpCode && error.message) { + return Q.reject(error); + } else { + return Q.reject(utils.createError(code, 'Database offline.', httpStatus.BAD_GATEWAY)); + } + }); +} diff --git a/node_server/ComServe/mainDB.js b/node_server/ComServe/mainDB.js new file mode 100644 index 0000000..0caf356 --- /dev/null +++ b/node_server/ComServe/mainDB.js @@ -0,0 +1,2702 @@ +/* eslint-disable consistent-return */ +/* eslint-disable no-negated-condition */ +/* eslint-disable jsdoc/check-tag-names */ +/* eslint-disable jsdoc/check-types */ +/* eslint-disable complexity */ +/* eslint-disable lodash/prefer-lodash-typecheck */ +/* eslint-disable no-throw-literal */ + +/** + * @fileOverview Node.js Main Database Functionality for Bridge Pay + * @preserve Copyright 2015 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ + +/** + * Includes + */ +const mongodb = require('mongodb'); +const async = require('async'); + +const log = require(global.pathPrefix + 'log.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const valid = require(global.pathPrefix + 'valid.js'); +const auth = require(global.pathPrefix + 'auth.js'); +const config = require(global.configFile); +const _ = require('lodash'); + +const anon = require(global.pathPrefix + '../utils/anon.js'); + +/** + * Database variables. Change these to adjust behaviour. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/} + */ + +// Export functions +exports.addObject = addObject; +exports.addMany = addMany; +exports.findOneObject = findOneObject; +exports.updateObject = updateObject; +exports.removeObject = removeObject; + +exports.blankClient = blankClient; +exports.blankDevice = blankDevice; +exports.blankAccount = blankAccount; +exports.blankCreditDebitCard = blankCreditDebitCard; +exports.blankWorldpayOnlinePayments = blankWorldpayOnlinePayments; +exports.blankTransaction = blankTransaction; +exports.blankTransactionHistory = blankTransactionHistory; +exports.blankMCAdminAccount = blankMCAdminAccount; +exports.blankAddress = blankAddress; +exports.updateCoordinates = updateCoordinates; +exports.updateAccountCollection = updateAccountCollection; +exports.updateClientCollection = updateClientCollection; +exports.updateDeviceCollection = updateDeviceCollection; +exports.updateTransactionHistoryCollection = updateTransactionHistoryCollection; +exports.updateTransactionCollection = updateTransactionCollection; +exports.updateDatabase = updateDatabase; + +exports.dbOnline = 0; +if (process.argv[2]) { + exports.dbAddress = config.externaldbAddress; +} else { + exports.dbAddress = config.internaldbAddress; +} +exports.mdb = null; +exports.dbAccount = 'Account'; +exports.collectionAccount = null; +exports.dbAccountArchive = 'AccountArchive'; +exports.collectionAccountArchive = null; +exports.dbPaymentInstrument = 'PaymentInstrument'; +exports.collectionPaymentInstrument = null; +exports.dbPaymentInstrumentArchive = 'PaymentInstrumentArchive'; +exports.collectionPaymentInstrumentArchive = null; +exports.dbAddresses = 'Address'; +exports.collectionAddresses = null; +exports.dbAddressArchive = 'AddressArchive'; +exports.collectionAddressArchive = null; +exports.dbBridgeLogin = 'BridgeLogin'; +exports.collectionBridgeLogin = null; +exports.dbClient = 'Client'; +exports.collectionClient = null; +exports.dbClientArchive = 'ClientArchive'; +exports.collectionClientArchive = null; +exports.dbDevice = 'Device'; +exports.collectionDevice = null; +exports.dbDeviceArchive = 'DeviceArchive'; +exports.collectionDeviceArchive = null; +exports.dbImages = 'Images'; +exports.collectionImages = null; +exports.dbItems = 'Items'; +exports.collectionItems = null; +exports.dbMessages = 'Messages'; +exports.collectionMessages = null; +exports.dbMessagesArchive = 'MessagesArchive'; +exports.collectionMessagesArchive = null; +exports.dbPayCode = 'PayCode'; +exports.collectionPayCode = null; +exports.dbLog = 'SystemLog'; +exports.collectionSystemLog = null; +exports.dbTransaction = 'Transaction'; +exports.collectionTransaction = null; +exports.dbTransactionArchive = 'TransactionArchive'; +exports.collectionTransactionArchive = null; +exports.dbTransactionHistory = 'TransactionHistory'; +exports.collectionTransactionHistory = null; +exports.dbTwoFARequests = 'TwoFARequests'; +exports.collectionTwoFARequests = null; +exports.dbActivityLog = 'ActivityLog'; +exports.collectionActivityLog = null; +exports.MClient = mongodb.MongoClient; + +/** + * Add an object to a Mongo collection. This will not work if the database is offline. + */ +function addObject(collection, object, options, suppress, next) { + let infoString = ''; + + /** + * @type {function} addObject + * @param {!object} collection - The Mongo collection to add to. + * @param {!object} object - The document to add in JSON format. + * @param {!object} options - Options for the insert command. Use 'undefined' if there are none. + * @param {!boolean} suppress - Database errors should be handled by callback if true; do not switch DB offline. + * @param {?function} next - Optional callback for async operation. Must be present if supress === true. + * @param {?object} next.err - Error object. null on success. + * @param {?object[]} next.result.ops - An array of objects added if successful. + */ + try { + /** + * Check to see if the database is online. + */ + if (exports.dbOnline) { + collection.insert(object, options, (err, result) => { + if (err) { + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Database error; cannot store info. TRIED TO WRITE: ' + JSON.stringify(object); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(err); + log.system( + 'CRITICAL', + infoString, + 'mainDB.addObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(err, null); + } + } else if (next) { + return next(null, result.ops); + } + }); + } else { + /** + * Log to console. The watchdog will restart the database. + */ + infoString = 'Database offline; cannot store info. TRIED TO WRITE: ' + JSON.stringify(object); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + log.system( + 'CRITICAL', + infoString, + 'mainDB.addObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next('Critical: Database offline.', null); + } + } + } catch (error) { + /** + * Catch unexpected errors. + */ + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Unexpected database error. TRIED TO WRITE: ' + JSON.stringify(object); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(error); + log.system( + 'CRITICAL', + infoString, + 'mainDB.addObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(error, null); + } + } +} + +/** + * Callback from the MongoDB collection.insertMany() response. + * See {@link http://mongodb.github.io/node-mongodb-native/2.2/api/Collection.html#~insertWriteOpCallback} + * + * @callback addManyCallback + * @param {MongoError} error - the error from the MongoDB call + * @param {insertWriteOpResult} - the result from a successful call + */ + +/** + * Adds many objects to the Mongo collection specified (using insertMany). + * See {@link http://mongodb.github.io/node-mongodb-native/2.2/api/Collection.html#insertMany} + * + * @param {Object} collection - The collection to store entries to + * @param {Object[]} objects - Array of objects to store + * @param {Object} options - Options to pass to insertMany() + * @param {boolean} suppress - falsy = errors mark the db offline, truthy = not marked offline + * @param {addManyCallback} [next] - optional callback for the result + * + * @returns {any} - The result of calling next() or undefined. + */ +function addMany(collection, objects, options, suppress, next) { + let infoString = ''; + + try { + /** + * Check to see if the database is online. + */ + if (exports.dbOnline) { + collection.insertMany(objects, options, (err, result) => { + if (err) { + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Database error; cannot store info. TRIED TO WRITE: ' + + objects.length + + ' items.'; + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(err); + log.system( + 'CRITICAL', + infoString, + 'mainDB.addObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(err, null); + } + } else if (next) { + return next(null, result); + } + }); + } else { + /** + * Log to console. The watchdog will restart the database. + */ + infoString = 'Database offline; cannot store info. TRIED TO WRITE: ' + + objects.length + + ' items.'; + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + log.system( + 'CRITICAL', + infoString, + 'mainDB.addObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next('Critical: Database offline.', null); + } + } + } catch (error) { + /** + * Catch unexpected errors. + */ + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Unexpected database error. TRIED TO WRITE: ' + + objects.length + + ' items.'; + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(error); + log.system( + 'CRITICAL', + infoString, + 'mainDB.addObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(error, null); + } + } +} + +/** + * Finds the first instance of an object in a database. This will not work if the database is offline. + * Mostly a wrapper for the MongoClient collection.findOne(). + * + * @see {@link http://mongodb.github.io/node-mongodb-native/2.1/api/Collection.html#findOne} + * + */ +function findOneObject(collection, query, options, suppress, next) { + let infoString = ''; + + /** + * @type {function} findOneObject + * @param {!object} collection - The Mongo collection to search. + * @param {!object} query - The search parameters in JSON format. + * @param {?object} options - The optional settings for the search. Use 'undefined' if there are no options. + * @param {!boolean} suppress - Database errors should be handled by callback if true; do not switch DB offline. + * @param {!function} next - Required callback for async operation. + * @param {?object} next.err - Error object. null on success. + * @param {?object} next.result - The first matched object. null if there are no matches. + */ + try { + /** + * Check to see if the database is online. + */ + if (exports.dbOnline) { + collection.findOne(query, options, (err, result) => { + if (err) { + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Database error; cannot find object. TRIED TO FIND: ' + JSON.stringify(query); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(err); + log.system( + 'CRITICAL', + infoString, + 'mainDB.findOneObject', + '', + 'System', + '127.0.0.1'); + return next(err, null); + } else { + return next(null, result); + } + }); + } else { + /** + * Log to console. The watchdog will restart the database. + */ + infoString = 'Database offline; cannot find object. TRIED TO FIND: ' + JSON.stringify(query); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + log.system( + 'CRITICAL', + infoString, + 'mainDB.findOneObject', + '', + 'System', + '127.0.0.1'); + return next('Error: Database offline.', null); + } + } catch (error) { + /** + * Catch unexpected errors. + */ + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Unexpected database error. TRIED TO FIND: ' + JSON.stringify(query); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + log.system( + 'CRITICAL', + infoString, + 'mainDB.findOneObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(error, null); + } + } +} + +/** + * Updates an object in the database. This will not work if the database is offline. + */ +function updateObject(collection, query, update, options, suppress, next) { + let infoString = ''; + + /** + * @type {function} updateObject + * @param {!object} collection - The Mongo collection in which the object exists. + * @param {!object} query - The search parameters in JSON format. + * @param {!object} update - The values to update in JSON format. + * @param {?object} options - Any operation options in JSON format. Use 'undefined' if there are no options. + * @param {!boolean} suppress - Database errors should be handled by callback if true; do not switch DB offline. + * @param {?function} next - Optional callback for async operation. + * @param {?object} next.err - Error object. null on success. + * @param {?object} next.res - Result object. + */ + try { + /** + * Check to see if the database is online. + */ + if (exports.dbOnline) { + collection.update(query, update, options, (err, res) => { + if (err) { + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Database error; cannot update object. QUERY: ' + JSON.stringify(query); + infoString += ', UPDATE: ' + JSON.stringify(update); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(err); + log.system( + 'CRITICAL', + infoString, + 'mainDB.updateObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(err, res); + } + } else if (next) { + return next(null, res); + } + }); + } else { + /** + * Log to console. The watchdog will restart the database. + */ + infoString = 'Database offline; cannot update object. QUERY: ' + JSON.stringify(query); + infoString += ', UPDATE: ' + JSON.stringify(update); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + log.system( + 'CRITICAL', + infoString, + 'mainDB.updateObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next('Error: Database offline.'); + } + } + } catch (error) { + /** + * Catch unexpected errors. + */ + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Unexpected database error. QUERY: ' + JSON.stringify(query); + infoString += ', UPDATE: ' + JSON.stringify(update); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(error); + log.system( + 'CRITICAL', + infoString, + 'mainDB.updateObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(error); + } + } +} + +/** + * Remove an object from the database. This will not work if the database is offline. + */ +function removeObject(collection, query, options, suppress, next) { + let infoString = ''; + + /** + * @type {function} removeObject + * @param {!object} collection - The Mongo collection in which the object exists. + * @param {!object} query - The search parameters for the object(s) to delete in JSON format. + * @param {?object} options - Any operation options in JSON format. Use 'undefined' if there are no options. + * @param {!boolean} suppress - Database errors should be handled by callback if true; do not switch DB offline. + * @param {?function} next - Optional callback for async operation. + * @param {?object} next.err - Error object. null on success. + */ + try { + /** + * Check to see if the database is online. + */ + if (exports.dbOnline) { + collection.remove(query, options, (err) => { + if (err) { + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Database error; cannot remove info. TRIED TO REMOVE: ' + JSON.stringify(query); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(err); + log.system( + 'CRITICAL', + infoString, + 'mainDB.removeObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(err); + } + } else if (next) { + return next(null); + } + }); + } else { + /** + * Log to console. The watchdog will restart the database. + */ + infoString = 'Database offline; cannot remove info. TRIED TO REMOVE: ' + JSON.stringify(query); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + log.system( + 'CRITICAL', + infoString, + 'mainDB.removeObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next('Critical: Database offline.'); + } + } + } catch (error) { + /** + * Catch unexpected errors. + */ + if (!suppress) { + exports.dbOnline = 0; + } + infoString = 'Unexpected database error. TRIED TO REMOVE: ' + JSON.stringify(query); + if (options) { + infoString += ', OPTIONS: ' + JSON.stringify(options); + } + infoString += ', ERROR: ' + JSON.stringify(error); + log.system( + 'CRITICAL', + infoString, + 'mainDB.removeObject', + '', + 'System', + '127.0.0.1'); + if (next) { + return next(error); + } + } +} + +/** + * Creates a blank client data structure. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/client} + * + * @type {function} blankClient + * @return {object} newClient - Empty client object as defined in documentation. + */ +function blankClient() { + const newClient = {}; + const defaultDate = new Date(0); + + /** + * Generate random id for created user + */ + newClient.ClientID = utils.timeBasedRandomCode(); + + /** + * Create structure. + */ + newClient.ClientName = ''; + newClient.MaxDevices = 3; + newClient.EMailValidationToken = ''; + newClient.EMailValidationTokenExpiry = defaultDate; + newClient.DisplayName = 'New User'; + newClient.Selfie = config.defaultSelfie; + newClient.OperatorName = ''; + newClient.PromoCode = ''; + newClient.EULAVersionAccepted = config.EULAVersion; + newClient.FirstLogin = 1; + newClient.LoginAttempts = 0; + newClient.Password = ''; + newClient.ClientSalt = ''; + newClient.ClientType = '1'; + newClient.ClientStatus = 0x0; + newClient.SessionToken = ''; + newClient.SessionAuthorisation = ''; + newClient.SessionTokenExpiry = defaultDate; + newClient.LastUpdate = defaultDate; + newClient.LastVersion = 1; + + /** + * Non-standard database key names. Disable error message. + */ + newClient.KYC = [{ + Title: '', + FirstName: '', + LastName: '', + MiddleNames: '', + ContactEmail: '', + DateOfBirth: '', + DriversLicense: '', + PassportNumber: '', + PassportExpiry: '', + ResidentialAddressID: '', + Gender: '', + Smartscore: -1 /* Use -1 as initial value */ + }]; + newClient.PasswordManagement = [{ + PasswordExpiry: defaultDate, + PasswordLastReset: defaultDate, + PasswordReset: '0' + }]; + newClient.Merchant = [{ + MerchantStatus: 0, + MerchantExpiry: defaultDate, + CompanyName: '', + CompanyAlias: '', + CompanySubName: '', + VATNo: null, + CompanyLogo: config.defaultCompanyLogo0 + }]; + newClient.ClientPreferences = [{ + DefaultAccount: '', + DefaultLanguage: 'English', + DefaultCurrency: 'GBP', + PageDisplayType: '1' + }]; + newClient.FeatureFlags = [ + 'cardpayments', + 'vat' + ]; + + /** + * Return populated object. + */ + return newClient; +} + +/** + * Creates a blank device data structure. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/device} + * + * @type {function} blankDevice + * @return {object} newDevice - Empty device object as defined in documentation. + */ +function blankDevice() { + const newDevice = {}; + + /** + * Create structure. + */ + newDevice.DeviceName = 'My Phone'; + newDevice.DeviceUuid = ''; + newDevice.DeviceHardware = ''; + newDevice.DeviceSoftware = ''; + newDevice.DeviceNumber = ''; + newDevice.DeviceStatus = 0x0; + newDevice.DefaultAccount = ''; + newDevice.DeviceAuthorisation = ''; + newDevice.DeviceSalt = ''; + newDevice.DeviceToken = ''; + newDevice.ClientID = ''; + newDevice.RegistrationToken = ''; + newDevice.RegistrationTokenExpiry = new Date(0); + newDevice.RegistrationTokenAttempts = 0; + newDevice.SignupLocation = null; + newDevice.SignupIP = ''; + newDevice.LastLoginLocation = null; + newDevice.LastLoginIP = ''; + newDevice.LastLogin = new Date(0); + newDevice.SessionToken = ''; + newDevice.SessionTokenExpiry = new Date(0); + newDevice.LoginAttempts = 0; + newDevice.LastUpdate = new Date(0); + newDevice.LastVersion = 1; + newDevice.APIVersion = config.CCServerVersion; + newDevice.Integrity = null; + newDevice.PendingHMAC = utils.randomCode(utils.lowerCaseHex, (config.HMACBytes * 2)); + newDevice.CurrentHMAC = ''; + newDevice.HMACAttempts = 0; + + /** + * Return populated object. + */ + return newDevice; +} + +/** + * Creates a blank account data structure. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/account} + * + * @type {function} blankAccount + * @return {object} newAccount - Empty account object as defined in documentation. + */ +function blankAccount() { + const newAccount = {}; + + /** + * Create structure. + */ + newAccount.ClientID = ''; + newAccount.BillingAddress = ''; + newAccount.VendorID = ''; + newAccount.VendorAccountName = ''; + newAccount.NameOnAccount = ''; + newAccount.ClientAccountName = ''; + newAccount.AccountType = ''; + newAccount.IconLocation = ''; + newAccount.UserImage = ''; + newAccount.ReceivingAccount = 0; + newAccount.PaymentsAccount = 0; + newAccount.AccountNumber = ''; + newAccount.SortCode = ''; + newAccount.CardPAN = ''; + newAccount.CardPANEncrypted = ''; + newAccount.CardValidFromEncrypted = ''; + newAccount.CardExpiryEncrypted = ''; + newAccount.IssueNumberEncrypted = ''; + newAccount.EncryptionKey = ''; + newAccount.Token = ''; + newAccount.TokenisationID = ''; + newAccount.RiskScore = ''; + newAccount.AccountStatus = 0; + newAccount.TransactionTotal = 0; + newAccount.TotalDeposits = 0; + newAccount.TotalWithdrawals = 0; + newAccount.TotalAdjustments = 0; + newAccount.BalanceAvailable = 0; + newAccount.Balance = null; + newAccount.AcquirerName = ''; + newAccount.AcquirerMerchantID = ''; + newAccount.AcquirerCipher = ''; + newAccount.Limits = {}; + newAccount.APIVersion = config.CCServerVersion; + newAccount.Integrity = null; + newAccount.LastUpdate = new Date(0); + newAccount.LastVersion = 1; + + /** + * Return populated object. + */ + return newAccount; +} + +/** + * All payment instruments have the same base fields. This returns an object + * with those fields initialised. + * + * @returns {Object} - a base payment instrument for specialisation by type + */ +function blankPaymentInstrumentBase() { + return { + UserID: '', + VendorID: '', + VendorAccountName: '', + Description: '', + AccountType: utils.PaymentInstrumentType.UNSPECIFIED, + IconLocation: '', + ReceivingAccount: 0, + PaymentsAccount: 0, + APIVersion: config.CCServerVersion, + Integrity: null, + LastUpdate: new Date(0), + LastVersion: 1 + }; +} + +/** + * Creates a blank PaymentInstrument data structure, specialised for the Credit/Debit Card type + * + * @see {@link https://comcarde.atlassian.net/wiki/spaces/TA/pages/32866343/Payment+Instrument} + * + * @return {Object} - Empty PaymentInstrument object as defined in documentation. + */ +function blankCreditDebitCard() { + const newCard = { + + /** + * Create structure. + */ + AccountType: utils.PaymentInstrumentType.CREDIT_DEBIT_PAYMENT_CARD, + ReceivingAccount: 0, + PaymentsAccount: 1, + + CreditDebitCardInfo: { + BillingAddress: '', + NameOnAccount: '', + CardPAN: '', + CardPANEncrypted: '', + CardValidFromEncrypted: '', + CardExpiryEncrypted: '', + IssueNumberEncrypted: '', + Email: '', + FirstName: '', + LastName: '' + } + }; + + _.defaults(newCard, blankPaymentInstrumentBase()); + + /** + * Return populated object. + */ + return newCard; +} + +/** + * Creates a blank PaymentInstrument data structure, specialised for "Worldpay Online Payments Account" + * + * @see {@link https://comcarde.atlassian.net/wiki/spaces/TA/pages/32866343/Payment+Instrument} + * + * @return {Object} - Empty PaymentInstrument object as defined in documentation. + */ +function blankWorldpayOnlinePayments() { + const newRecord = { + + /** + * Create structure. + */ + AccountType: utils.PaymentInstrumentType.WORLDPAY_ONLINE_PAYMENTS_ACCOUNT, + ReceivingAccount: 1, + PaymentsAccount: 0, + + WorldpayOnlinePaymentsInfo: { + ServiceKey: '', + ServiceKeyEncrypted: '' + } + }; + + _.defaults(newRecord, blankPaymentInstrumentBase()); + + /** + * Return populated object. + */ + return newRecord; +} + +/** + * Creates a blank transaction data structure. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/transaction} + * + * @type {function} blankTransaction + * @return {object} newTrans - Empty transaction object as defined in documentation. + * + */ +function blankTransaction() { + const newTrans = {}; + + /** + * Create structure. + */ + newTrans.PayCode = ''; + newTrans.PayCodeID = ''; + newTrans.PayCodeExpiry = new Date(0); + newTrans.CustomerDeviceToken = ''; + newTrans.CustomerSessionToken = ''; + newTrans.CustomerAccountID = ''; + newTrans.CustomerClientID = ''; + newTrans.CustomerDisplayName = ''; + newTrans.CustomerSubDisplayName = ''; + newTrans.CustomerImage = ''; + newTrans.CustomerVATNo = null; + newTrans.MerchantDeviceToken = ''; + newTrans.MerchantSessionToken = ''; + newTrans.MerchantAccountID = ''; + newTrans.MerchantClientID = ''; + newTrans.MerchantDisplayName = ''; + newTrans.MerchantSubDisplayName = ''; + newTrans.MerchantVATNo = null; + newTrans.MerchantImage = ''; + newTrans.MerchantUserName = ''; + newTrans.MerchantLoyaltyScheme = ''; + newTrans.CustomerLoyaltyNumber = ''; + newTrans.MerchantInvoice = null; + newTrans.MerchantComment = ''; + newTrans.TransactionStatus = 0; + newTrans.StatusInfo = ''; + newTrans.RequestAmount = 0; + newTrans.TipAmount = null; + newTrans.PromoCode = ''; + newTrans.PromoAmount = 0; + newTrans.TotalAmount = 0; + newTrans.Settled = 0; + newTrans.AmountRefunded = 0; + newTrans.CustomerLocation = null; + newTrans.MerchantLocation = null; + newTrans.SaleTime = new Date(0); + newTrans.AcquirerName = ''; + newTrans.AcquirerMerchantID = ''; + newTrans.AcquirerCipher = ''; + newTrans.SaleReference = ''; + newTrans.SaleAuthCode = ''; + newTrans.RefundToken = ''; + newTrans.RiskScore = ''; + newTrans.GatewayResponse = ''; + newTrans.AVSResponse = ''; + newTrans.LastUpdate = new Date(0); + newTrans.LastVersion = 1; + newTrans.APIVersion = config.CCServerVersion; + newTrans.Integrity = null; + + /** + * Return populated object. + */ + return newTrans; +} + +/** + * Creates a blank transaction history data structure. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/transactionhistory} + * + * @type {function} blankTransactionHistory + * @return {object} newHist - Empty transaction history object as defined in documentation. + */ +function blankTransactionHistory() { + const newHist = {}; + + /** + * Create structure. + */ + newHist.TransactionID = ''; + newHist.TransactionType = 0; + newHist.AccountID = ''; + newHist.ClientID = ''; + newHist.OtherDisplayName = ''; + newHist.OtherSubDisplayName = ''; + newHist.MyLocation = null; + newHist.TotalAmount = 0; + newHist.SaleTime = new Date(0); + newHist.LastUpdate = new Date(0); + newHist.LastVersion = 1; + newHist.APIVersion = config.CCServerVersion; + newHist.Integrity = null; + + /** + * Return populated object. + */ + return newHist; +} + +/** + * Creates a blank management console user account structure. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/mcadmin} + * + * @type {function} blankMCAdminAccount + * @return {object} newAccount - Empty admin account object as defined in documentation. + */ +function blankMCAdminAccount() { + const newAccount = {}; + + /** + * Create structure. + */ + newAccount.password = ''; + newAccount.email = ''; + newAccount.key = ''; + newAccount.sessionToken = ''; + newAccount.access = { + addMCAdmin: 0, + getSystemInfo: 0, + shutdown: 0, + consoleLog: 0, + editAnAccount: 0 + }; + newAccount.ObjectAdded = new Date(0); + newAccount.sessionTokenExpiry = new Date(0); + + /** + * Return populated object. + */ + return newAccount; +} + +/** + * Creates a blank address data structure. + * + * @see {@link http://10.0.10.242/w/tricore_architecture/database_design/collections/address} + * + * @type {function} blankAddress + * @return {object} newAddress - Empty address object as defined in documentation. + */ +function blankAddress() { + const newAddress = {}; + + /** + * Create structure. + */ + newAddress.UserID = ''; + newAddress.AddressDescription = ''; + newAddress.BuildingNameFlat = ''; + newAddress.Address1 = ''; + newAddress.Address2 = ''; + newAddress.Town = ''; + newAddress.County = ''; + newAddress.PostCode = ''; + newAddress.Country = ''; + newAddress.PhoneNumber = ''; + newAddress.ResidentTo = ''; + newAddress.ResidentFrom = ''; + newAddress.DateAdded = new Date(0); + newAddress.LastUpdate = new Date(0); + newAddress.LastVersion = 1; + + /** + * Return populated object. + */ + return newAddress; +} + +/** + * Takes an old co-ordinate and updates it to the new version. The location is the raw data to check. + * + * @type {function} updateCoordinates + * @param {!object} location - The GeoJSON point to check for old coordinate formatting. + * @return {object} The changes to make using $set in JSON format. A boolean false indicates no changes or problem data. + * + * Odd cyclomatic complexity error disabled. + */ +function updateCoordinates(location) { + /** + * Check that it's not null. + */ + if (location !== null) { + /** + * Check for non object - usually indicates a problem so blank and return. Also protects against the use of + * 'in' on a non object. + */ + if (typeof location !== 'object') { + return null; + } + + /** + * This comparison will try to convert either if they are strings. + * If one or both conversions fail, the location will be blanked. + */ + if ('coordinates' in location) { + let newLocation; + if ((typeof location.coordinates[0] === 'string') && (typeof location.coordinates[1] === 'string')) { + /** + * One of the variables is a string so this is an old version. Convert as appropriate. + */ + if ((location.coordinates[0] === '') || + (location.coordinates[1] === '') || + (location.coordinates[0] === '0.0') || + (location.coordinates[1] === '0.0')) { + /** + * Invalid co-ordinate. Blank the field completely. + */ + newLocation = null; + } else { + /** + * Valid numbers so convert to 8dp. + */ + newLocation = location; + newLocation.coordinates[0] = Number(parseFloat(location.coordinates[0]).toFixed(8)); + newLocation.coordinates[1] = Number(parseFloat(location.coordinates[1]).toFixed(8)); + + /** + * Check for string errors. + */ + if (isNaN(newLocation.coordinates[0]) || isNaN(newLocation.coordinates[1])) { + newLocation = null; + } + } + + /** + * Changes made. + */ + return newLocation; + } + + /** + * Error where NaN was written to the database. + */ + if ((isNaN(location.coordinates[0])) || (isNaN(location.coordinates[1]))) { + newLocation = null; + return newLocation; + } + + /** + * Reduces the precision of any numbers already in the database that exceed 8dp. + */ + if ((typeof location.coordinates[0] === 'number') && (typeof location.coordinates[1] === 'number')) { + if ((valid.checkDP(location.coordinates[0]) > 8) || (valid.checkDP(location.coordinates[1]) > 8)) { + newLocation = location; + newLocation.coordinates[0] = Number(location.coordinates[0].toFixed(8)); + newLocation.coordinates[1] = Number(location.coordinates[1].toFixed(8)); + return newLocation; + } + } + } + } + + /** + * Note the boolean return if no changes are necessary or input is not recognised. + */ + return false; +} + +/** + * This scan checks the account collection and updates where appropriate. + * Note that this update process does not change LastUpdate. This is deliberate. + * + * @type {function} updateAccountCollection + * @param {!object} accountCollection - The collection to be updated. + * @param {!object} accountArchiveCollection - The collection to which old accounts should be moved. + * @param {!object} addressCollection - The collection from which Addresses are sourced. + * @param {!string} dbName - The database name for differentiation and the log files; Only 'Account' at the moment. + * + * Cyclomatic complexity error disabled. + */ +function updateAccountCollection(accountCollection, accountArchiveCollection, addressCollection, dbName) { + /** + * Check each entry. This is potentially a long scan. + */ + accountCollection.find().forEach((existingAccount) => { + const toUpdate = {}; + const toDelete = {}; + async.series([ + function(callback) { + /** + * Firstly, run integrity checks and flag any problems with the record. + * Integrity checks will not run until the account has been upgraded. + */ + if ((config.databaseIntegrityCheck) && ('Integrity' in existingAccount) && (config.CCServerVersion === '7.2.815')) { + const integrityResult = []; + if (Object.keys(existingAccount).length !== 37) { + integrityResult.push(utils.ACCOUNT_ERR.ERR_KEYS); + } + if ('BillingAddress' in existingAccount) { + if (existingAccount.BillingAddress === '') { + integrityResult.push(utils.ACCOUNT_ERR.NO_BILLING_ADD); + } + } + if ((existingAccount.TotalDeposits - existingAccount.TotalWithdrawals) !== + (existingAccount.Balance + existingAccount.TotalAdjustments)) { + integrityResult.push(utils.ACCOUNT_ERR.ERR_BALANCE); + } + if ((existingAccount.TotalDeposits + existingAccount.TotalWithdrawals + existingAccount.TotalAdjustments) !== + existingAccount.TransactionTotal) { + integrityResult.push(utils.ACCOUNT_ERR.ERR_TOTAL); + } + + /** + * Check for any results and append. + */ + if (integrityResult.length !== 0) { + /** + * If there has been no change in the errors since last time, don't update but do log. + */ + if ((existingAccount.Integrity !== null) && + (_.isEqual(integrityResult.sort(), existingAccount.Integrity.sort()))) { + log.system( + 'INFO', + (dbName + ' _id ' + existingAccount._id + ' (' + existingAccount.ClientID + + ') existing problem unchanged: {"Integrity":' + JSON.stringify(integrityResult) + '}'), + 'mainDB.updateAccountCollection', + '', + 'System', + '127.0.0.1'); + } else { + toUpdate.Integrity = integrityResult; + } + } else if (existingAccount.Integrity !== null) { + /** + * Don't update if it's the same and there are no problems. + */ + toUpdate.Integrity = null; + } + } + callback(null); + }, + function(callback) { + /** + * Alpha 2 release upgrade path from Alpha 1. Protected by version number to prevent accidental changes. + */ + if (config.CCServerVersion === '7.2.815') { + let tempString = ''; + if (!('BillingAddress' in existingAccount)) { + toUpdate.BillingAddress = ''; + } + if (!('APIVersion' in existingAccount)) { + toUpdate.APIVersion = config.CCServerVersion; + } + if (!('Integrity' in existingAccount)) { + toUpdate.Integrity = ['Integrity not verified.']; + } + if ('TotalWithdrawls' in existingAccount) { + toUpdate.TotalWithdrawals = existingAccount.TotalWithdrawls; + toDelete.TotalWithdrawls = 1; + } + if (!('CardPANEncrypted' in existingAccount)) { + toUpdate.CardPANEncrypted = ''; + if (existingAccount.CardPAN.length === 16) { + /** + * Starred data only - no original data. Update format of information. + */ + toUpdate.CardPAN = anon.anonymiseCardPAN(existingAccount.CardPAN); + } else if (existingAccount.CardPAN.length > 16) { + /** + * Data present - likely encrypted. + */ + const splitData = existingAccount.CardPAN.split('::'); + + /** + * Process depending on encryption version. + */ + if (splitData.length === 1) { + tempString = utils.decryptAES256(splitData[0], config.AESKey, 'aes-256-ctr'); + + /** + * Ensure there is decrypted data to store. + */ + if (tempString) { + toUpdate.CardPAN = anon.anonymiseCardPAN(tempString); + toUpdate.CardPANEncrypted = utils.encryptDataV1(tempString); + } else { + toUpdate.CardPANEncrypted = existingAccount.CardPAN; + } + } else { + /** + * Default action is just store as we do not want to lose data. + */ + toUpdate.CardPANEncrypted = existingAccount.CardPAN; + } + } else { + /** + * Default action is just store as we do not want to lose data. + */ + toUpdate.CardPANEncrypted = existingAccount.CardPAN; + } + } + if (!('CardValidFromEncrypted' in existingAccount)) { + toUpdate.CardValidFromEncrypted = ''; + if (existingAccount.CardValidFrom.length > 0) { + tempString = utils.decryptAES256(existingAccount.CardValidFrom, config.AESKey, 'aes-256-ctr'); + if (tempString) { + toUpdate.CardValidFromEncrypted = utils.encryptDataV1(tempString); + } else { + toUpdate.CardValidFromEncrypted = existingAccount.CardValidFrom; + } + } + toDelete.CardValidFrom = 1; + } + if (!('CardExpiryEncrypted' in existingAccount)) { + toUpdate.CardExpiryEncrypted = ''; + if (existingAccount.CardExpiry.length > 0) { + tempString = utils.decryptAES256(existingAccount.CardExpiry, config.AESKey, 'aes-256-ctr'); + if (tempString) { + toUpdate.CardExpiryEncrypted = utils.encryptDataV1(tempString); + } else { + toUpdate.CardExpiryEncrypted = existingAccount.CardExpiry; + } + } + toDelete.CardExpiry = 1; + } + if (!('IssueNumberEncrypted' in existingAccount)) { + toUpdate.IssueNumberEncrypted = ''; + if (existingAccount.IssueNumber.length > 0) { + tempString = utils.decryptAES256(existingAccount.IssueNumber, config.AESKey, 'aes-256-ctr'); + if (tempString) { + toUpdate.IssueNumberEncrypted = utils.encryptDataV1(tempString); + } else { + toUpdate.IssueNumberEncrypted = existingAccount.IssueNumber; + } + } + toDelete.IssueNumber = 1; + } + } + callback(null); + }, + function(callback) { + /** + * See if there is a billing address that can be matched to the account. + * Do not connect this to archived addresses. + */ + if (config.CCServerVersion === '7.2.815') { + if ('BillingAddress' in existingAccount) { + if (existingAccount.BillingAddress === '') { + addressCollection.find( + { + ClientID: existingAccount.ClientID, + AddressDescription: 'CurrentAddress' + }, + { + _id: 1 + } + ).toArray((err, addresses) => { + if (err) { + callback(err); + return; + } + + /** + * If there is only one "CurrentAddress" then add this to the account as it is an upgrade. + */ + if (addresses.length === 1) { + toUpdate.BillingAddress = addresses[0]._id.toString(); + callback(null); + return; + } + + /** + * No billing address. If there is only one address on the account then default it. + */ + addressCollection.find( + { + ClientID: existingAccount.ClientID, + AddressDescription: {$ne: 'CurrentAddress'} + }, + { + _id: 1 + } + ).toArray((error, moreAddresses) => { + if (error) { + callback(error); + return; + } + + /** + * If there is only one address then there is no ambiguity - update the account automatically. + * Don't do anything if there is abiguity or no address. PayCodeRequest and RedeemPayCode + * will not allow the transaction to proceed unless the user sets this address. + */ + if (moreAddresses.length === 1) { + toUpdate.BillingAddress = moreAddresses[0]._id.toString(); + } + callback(null); + }); + }); + return; + } + } + } + callback(null); + }, + function(callback) { + /** + * Update only if there are changes. + */ + if ((Object.keys(toUpdate).length !== 0) && (config.databaseUpdateWrite)) { + updateObject(accountCollection, {_id: mongodb.ObjectID(existingAccount._id)}, { + $set: toUpdate + }, + {upsert: false}, false, (err) => { + if (err) { + return callback(err); + } else { + return callback(null); + } + }); + return; + } + callback(null); + }, + function(callback) { + /** + * Delete only if there are changes. + */ + if ((Object.keys(toDelete).length !== 0) && (config.databaseUpdateWrite)) { + updateObject(accountCollection, {_id: mongodb.ObjectID(existingAccount._id)}, { + $unset: toDelete + }, + {upsert: false}, false, (err) => { + if (err) { + return callback(err); + } else { + return callback(null); + } + }); + return; + } + callback(null); + }, + function(callback) { + /** + * Remove unused accounts that have been deleted. Do not remove if there are updates or deletes pending. + */ + if ((Object.keys(toUpdate).length === 0) && + (Object.keys(toDelete).length === 0) && + (config.databaseArchiveAccounts)) { + if ((utils.bitsAllSet(existingAccount.AccountStatus, utils.AccountDeleted)) && + (existingAccount.TransactionTotal === 0)) { + /** + * Store the unused account. + */ + const AccountID = existingAccount._id; + existingAccount.AccountID = existingAccount._id.toString(); + delete existingAccount._id; + existingAccount.LastUpdate = new Date(); + + /** + * Back up the existing Account. + */ + addObject(accountArchiveCollection, existingAccount, undefined, false, (err) => { + if (err) { + callback(err); + return; + } + + /** + * Account added to archive. Delete from main account database. + */ + removeObject(accountCollection, {_id: AccountID}, undefined, false, (error) => { + if (error) { + callback(error); + return; + } + + /** + * Old account removed from database. + */ + log.system( + 'INFO', + (dbName + ' _id ' + AccountID.toString() + ' has been archived.'), + 'mainDB.updateAccountCollection', + '', + 'System', + '127.0.0.1'); + callback(null); + }); + }); + return; + } + } + callback(null); + } + ], + + /** + * Final clause which is executed after everything else or when an error is detected. + * An error is thrown if there is a problem which should be caught by a try statement. + */ + (err) => { + if (err) { + throw new Error(err); + } else { + /** + * Log any changes made. + */ + if (Object.keys(toUpdate).length !== 0) { + let updateString = dbName + ' _id ' + existingAccount._id + ' (' + existingAccount.ClientID; + if (config.databaseUpdateWrite) { + updateString += ') updated: ' + JSON.stringify(toUpdate); + } else { + updateString += ') update test (not written): ' + JSON.stringify(toUpdate); + } + log.system( + 'INFO', + updateString, + 'mainDB.updateAccountCollection', + '', + 'System', + '127.0.0.1'); + } + if (Object.keys(toDelete).length !== 0) { + let deleteString = dbName + ' _id ' + existingAccount._id + ' (' + existingAccount.ClientID; + if (config.databaseUpdateWrite) { + deleteString += ') deleted: ' + JSON.stringify(toDelete); + } else { + deleteString += ') delete test (not deleted): ' + JSON.stringify(toDelete); + } + log.system( + 'INFO', + deleteString, + 'mainDB.updateAccountCollection', + '', + 'System', + '127.0.0.1'); + } + } + }); + }); +} + +/** + * This scan checks the client collection and updates where appropriate. + * Note that this update process does not change LastUpdate. This is deliberate. + * + * @type {function} updateClientCollection + * @param {!object} clientCollection - The collection to be updated. + * @param {!object} addressCollection - The collection where addresses should be extracted to. + * @param {!string} dbName - The database name for differentiation and the log files. + * + * Cyclomatic complexity error disabled. + * Address camelcase error disabled - disable only works outside the function (?). + */ +function updateClientCollection(clientCollection, addressCollection, dbName) { + /** + * Check each entry. This is potentially a long scan. + */ + clientCollection.find().forEach((existingClient) => { + const toUpdate = {}; + const toDelete = {}; + async.series([ + function(callback) { + /** + * Firstly, run integrity checks and flag any problems with the record. + * Integrity checks will not run until the account has been upgraded. + */ + if ((config.databaseIntegrityCheck) && ('Integrity' in existingClient) && (config.CCServerVersion === '7.2.815')) { + const integrityResult = []; + if (dbName === 'ClientArchive') { + if (Object.keys(existingClient).length !== 28) { + integrityResult.push('Incorrect number of keys; 28 expected, ' + + Object.keys(existingClient).length + ' found.'); + } + } else { + if (Object.keys(existingClient).length !== 27) { + integrityResult.push('Incorrect number of keys; 27 expected, ' + + Object.keys(existingClient).length + ' found.'); + } + if (existingClient.EMailValidationTokenExpiry !== null) { + const timestamp = new Date(); + if (timestamp >= existingClient.EMailValidationTokenExpiry) { + integrityResult.push('Incomplete registration attempt. Marked for deletion.'); + } + } + } + + /** + * Check for any results and append. + */ + if (integrityResult.length !== 0) { + /** + * If there has been no change in the errors since last time, don't update but do log. + */ + if ((existingClient.Integrity !== null) && + (_.isEqual(integrityResult.sort(), existingClient.Integrity.sort()))) { + log.system( + 'INFO', + (dbName + ' _id ' + existingClient._id + ' (' + existingClient.ClientID + + ') existing problem unchanged: {"Integrity":' + JSON.stringify(integrityResult) + '}'), + 'mainDB.updateClientCollection', + '', + 'System', + '127.0.0.1'); + } else { + toUpdate.Integrity = integrityResult; + } + } else if (existingClient.Integrity !== null) { + /** + * Don't update if it's the same and there are no problems. + */ + toUpdate.Integrity = null; + } + } + callback(null); + }, + function(callback) { + /** + * Alpha 2 release upgrade path from Alpha 1. Protected by version number to prevent accidental changes. + */ + if (config.CCServerVersion === '7.2.815') { + if (!('APIVersion' in existingClient)) { + toUpdate.APIVersion = config.CCServerVersion; + } + if (!('Integrity' in existingClient)) { + toUpdate.Integrity = ['Integrity not verified.']; + } + if (!('MaxDevices' in existingClient)) { + toUpdate.MaxDevices = 3; + } + if (!('ClientSalt' in existingClient)) { + toUpdate.ClientSalt = ''; + } + if ((!('ClientID' in existingClient)) && (dbName === 'ClientArchive')) { + toUpdate.ClientID = null; + } + if (existingClient.EMailValidationTokenExpiry === '') { + toUpdate.EMailValidationTokenExpiry = null; + } + if ('OneTimeNotification' in existingClient) { + toDelete.OneTimeNotification = 1; + } + } + callback(null); + }, + function(callback) { + /** + * Store an old version billing address if it exists. + */ + if (config.CCServerVersion === '7.2.815') { + if ('CurrentAddress' in existingClient.KYC[0]) { + /** + * Check whether there is an address here that needs to be stored. + */ + if (utils.bitsAllSet(existingClient.ClientStatus, utils.ClientAddressMask)) { + /** + * Store the old format address by creating and populating a new address. + */ + const timestamp = new Date(); + const newAddress = blankAddress(); + newAddress.ClientID = existingClient.ClientID; + newAddress.AddressDescription = 'CurrentAddress'; + newAddress.BuildingNameFlat = existingClient.KYC[0].CurrentAddress[0].CA_BuildingName_Flat; + newAddress.Address1 = existingClient.KYC[0].CurrentAddress[0].CA_Address1; + newAddress.Address2 = existingClient.KYC[0].CurrentAddress[0].CA_Address2; + newAddress.Town = existingClient.KYC[0].CurrentAddress[0].CA_Town; + newAddress.County = existingClient.KYC[0].CurrentAddress[0].CA_County; + newAddress.PostCode = existingClient.KYC[0].CurrentAddress[0].CA_PostCode; + newAddress.Country = existingClient.KYC[0].CurrentAddress[0].CA_Country; + if ('ContactNumber' in existingClient.KYC[0]) { + if (existingClient.KYC[0].ContactNumber.charAt(0) === '0') { + newAddress.PhoneNumber = '+44' + existingClient.KYC[0].ContactNumber.substr(1, + existingClient.KYC[0].ContactNumber.length); + } else { + newAddress.PhoneNumber = existingClient.KYC[0].ContactNumber; + } + } + newAddress.DateAdded = timestamp; + newAddress.LastUpdate = timestamp; + + /** + * ClientArchive entries should also have the address removed but extra parameters are needed. + */ + if (dbName === 'ClientArchive') { + newAddress.AddressID = ''; + newAddress.LastVersion += 1; + } + + /** + * Tell the system what's happening and add the object to the addresses collection if write is enabled. + */ + let newAddressString = dbName + ' _id ' + existingClient._id + ' (' + existingClient.ClientID; + if (config.databaseUpdateWrite) { + newAddressString += ') address extracted and stored: ' + JSON.stringify(newAddress); + addObject(addressCollection, newAddress, undefined, false, (err) => { + if (err) { + callback(err); + return; + } + + /** + * Old address stored. + */ + log.system( + 'INFO', + newAddressString, + 'mainDB.updateClientCollection', + '', + 'System', + '127.0.0.1'); + callback(null); + }); + return; + } + + /** + * Test only - do nothing. + */ + newAddressString += ') address extracted (not stored): ' + JSON.stringify(newAddress); + log.system( + 'INFO', + newAddressString, + 'mainDB.updateClientCollection', + '', + 'System', + '127.0.0.1'); + } + } + } + callback(null); + }, + function(callback) { + /** + * Delete the old address - we can't get here unless it has been stored successfully. + */ + if (config.CCServerVersion === '7.2.815') { + if ('CurrentAddress' in existingClient.KYC[0]) { + const newKYC = existingClient.KYC; + if ('AddressHistory' in newKYC[0]) { + delete newKYC[0].AddressHistory; + } + if ('CurrentAddress' in newKYC[0]) { + delete newKYC[0].CurrentAddress; + } + if ('ContactNumber' in newKYC[0]) { + delete newKYC[0].ContactNumber; + } + toUpdate.KYC = newKYC; + } + } + callback(null); + }, + function(callback) { + /** + * Split up the existing PIN and update if necessary. + */ + if (config.CCServerVersion === '7.2.815') { + if (existingClient.Password !== '') { + if (dbName === 'ClientArchive') { + toUpdate.Password = ''; + toUpdate.ClientSalt = ''; + } else { + const authArray = existingClient.Password.split('::'); + if (authArray.length === 1) { + /** + * Hash is an old version. Upgrade the hash. + */ + auth.encryptPBKDF2(existingClient.Password, (err, newSalt, newHash) => { + if (err) { + return callback(err); + } else { + toUpdate.ClientSalt = newSalt; + toUpdate.Password = config.pinCryptoVersion + '::' + newHash; + return callback(null); + } + }); + return; + } + } + } + } + callback(null); + }, + function(callback) { + /** + * Update only if there are changes. + */ + if ((Object.keys(toUpdate).length !== 0) && (config.databaseUpdateWrite)) { + updateObject(clientCollection, {_id: mongodb.ObjectID(existingClient._id)}, { + $set: toUpdate + }, + {upsert: false}, false, (err) => { + if (err) { + return callback(err); + } else { + return callback(null); + } + }); + return; + } + callback(null); + }, + function(callback) { + /** + * Delete only if there are changes. + */ + if ((Object.keys(toDelete).length !== 0) && (config.databaseUpdateWrite)) { + updateObject(clientCollection, {_id: mongodb.ObjectID(existingClient._id)}, { + $unset: toDelete + }, + {upsert: false}, false, (err) => { + if (err) { + return callback(err); + } else { + return callback(null); + } + }); + return; + } + callback(null); + } + ], + + /** + * Final clause which is executed after everything else or when an error is detected. + * An error is thrown if there is a problem which should be caught by a try statement. + */ + (err) => { + if (err) { + throw new Error(err); + } else { + /** + * Log any changes made. + */ + if (Object.keys(toUpdate).length !== 0) { + let updateString = dbName + ' _id ' + existingClient._id + ' (' + existingClient.ClientID; + if (config.databaseUpdateWrite) { + updateString += ') updated: ' + JSON.stringify(toUpdate); + } else { + updateString += ') update test (not written): ' + JSON.stringify(toUpdate); + } + log.system( + 'INFO', + updateString, + 'mainDB.updateClientCollection', + '', + 'System', + '127.0.0.1'); + } + if (Object.keys(toDelete).length !== 0) { + let deleteString = dbName + ' _id ' + existingClient._id + ' (' + existingClient.ClientID; + if (config.databaseUpdateWrite) { + deleteString += ') deleted: ' + JSON.stringify(toDelete); + } else { + deleteString += ') delete test (not deleted): ' + JSON.stringify(toDelete); + } + log.system( + 'INFO', + deleteString, + 'mainDB.updateClientCollection', + '', + 'System', + '127.0.0.1'); + } + } + }); + }); +} + +/** + * This scan checks the device collection and updates where appropriate. + * Note that this update process does not change LastUpdate. This is deliberate. + * + * @type {function} updateDeviceCollection + * @param {!object} deviceCollection - The collection to be updated. + * @param {!string} dbName - The database name for differentiation and the log files; either Device or DeviceArchive. + * + * Cyclomatic complexity error disabled. + */ +function updateDeviceCollection(deviceCollection, dbName) { + /** + * Check each entry. This is potentially a long scan. + */ + deviceCollection.find().forEach((existingDevice) => { + const toUpdate = {}; + async.series([ + function(callback) { + /** + * Firstly, run integrity checks and flag any problems with the record. + * Integrity checks will not run until the account has been upgraded. + */ + if ((config.databaseIntegrityCheck) && ('Integrity' in existingDevice) && (config.CCServerVersion === '7.2.815')) { + const integrityResult = []; + if (dbName === 'DeviceArchive') { + if (Object.keys(existingDevice).length !== 31) { + integrityResult.push('Incorrect number of keys; 31 expected, ' + + Object.keys(existingDevice).length + ' found.'); + } + } else { + if (Object.keys(existingDevice).length !== 30) { + integrityResult.push('Incorrect number of keys; 30 expected, ' + + Object.keys(existingDevice).length + ' found.'); + } + if (existingDevice.RegistrationTokenExpiry !== '') { + const timestamp = new Date(); + if (timestamp >= existingDevice.RegistrationTokenExpiry) { + integrityResult.push('Incomplete registration attempt. Marked for deletion.'); + } + } + } + + /** + * Check for any results and append. + */ + if (integrityResult.length !== 0) { + /** + * If there has been no change in the errors since last time, don't update but do log. + */ + if ((existingDevice.Integrity !== null) && + (_.isEqual(integrityResult.sort(), existingDevice.Integrity.sort()))) { + log.system( + 'INFO', + (dbName + ' _id ' + existingDevice._id + ' (' + existingDevice.ClientID + + ') existing problem unchanged: {"Integrity":' + JSON.stringify(integrityResult) + '}'), + 'mainDB.updateDeviceCollection', + '', + 'System', + '127.0.0.1'); + } else { + toUpdate.Integrity = integrityResult; + } + } else if (existingDevice.Integrity !== null) { + /** + * Don't update if it's the same and there are no problems. + */ + toUpdate.Integrity = null; + } + } + callback(null); + }, + function(callback) { + /** + * Alpha 2 release upgrade path from Alpha 1. Protected by version number to prevent accidental changes. + */ + if (config.CCServerVersion === '7.2.815') { + if (!('APIVersion' in existingDevice)) { + toUpdate.APIVersion = config.CCServerVersion; + } + if (existingDevice.DeviceNumber.charAt(0) === '0') { + toUpdate.DeviceNumber = '+44' + existingDevice.DeviceNumber.substr(1, existingDevice.DeviceNumber.length); + } + if (!('LastLoginLocation' in existingDevice)) { + toUpdate.LastLoginLocation = null; + } else { + const newLastLoginLocation = updateCoordinates(existingDevice.LastLoginLocation); + if (newLastLoginLocation !== false) { + toUpdate.LastLoginLocation = newLastLoginLocation; + } + } + if (!('LastLoginIP' in existingDevice)) { + toUpdate.LastLoginIP = ''; + } + if (!('LastLogin' in existingDevice)) { + toUpdate.LastLogin = new Date(0); + } + if (!('DeviceSalt' in existingDevice)) { + toUpdate.DeviceSalt = ''; + } + if (!('Integrity' in existingDevice)) { + toUpdate.Integrity = ['Integrity not verified.']; + } + if ('SignupLocation' in existingDevice) { + const newSignupLocation = updateCoordinates(existingDevice.SignupLocation); + if (newSignupLocation !== false) { + toUpdate.SignupLocation = newSignupLocation; + } + } + if (!('RegistrationTokenAttempts' in existingDevice)) { + toUpdate.RegistrationTokenAttempts = 0; + } + if ((!('DeviceIndex' in existingDevice)) && (dbName === 'DeviceArchive')) { + toUpdate.DeviceIndex = ''; + } + if (!('CurrentHMAC' in existingDevice)) { + toUpdate.CurrentHMAC = ''; + } else if ((dbName === 'DeviceArchive') && (existingDevice.CurrentHMAC !== '')) { + toUpdate.CurrentHMAC = ''; + } + if (!('PendingHMAC' in existingDevice)) { + if (dbName === 'DeviceArchive') { + toUpdate.PendingHMAC = ''; + } else { + toUpdate.PendingHMAC = utils.randomCode(utils.lowerCaseHex, (config.HMACBytes * 2)); + } + } else if ((dbName === 'DeviceArchive') && (existingDevice.PendingHMAC !== '')) { + toUpdate.PendingHMAC = ''; + } + if (!('HMACAttempts' in existingDevice)) { + toUpdate.HMACAttempts = 0; + } + } + callback(null); + }, + function(callback) { + /** + * Split up the existing PIN and update if necessary. + */ + if (config.CCServerVersion === '7.2.815') { + if (existingDevice.DeviceAuthorisation !== '') { + if (dbName === 'DeviceArchive') { + toUpdate.DeviceAuthorisation = ''; + toUpdate.DeviceSalt = ''; + } else { + const authArray = existingDevice.DeviceAuthorisation.split('::'); + if (authArray.length === 1) { + /** + * Hash is an old version. Upgrade the hash. + */ + auth.encryptPBKDF2(existingDevice.DeviceAuthorisation, (err, newSalt, newHash) => { + if (err) { + return callback(err); + } else { + toUpdate.DeviceSalt = newSalt; + toUpdate.DeviceAuthorisation = config.pinCryptoVersion + '::' + newHash; + return callback(null); + } + }); + return; + } + } + } + } + callback(null); + }, + function(callback) { + /** + * Update only if there are changes. + */ + if ((Object.keys(toUpdate).length !== 0) && (config.databaseUpdateWrite)) { + updateObject(deviceCollection, {_id: mongodb.ObjectID(existingDevice._id)}, { + $set: toUpdate + }, + {upsert: false}, false, (err) => { + if (err) { + return callback(err); + } else { + return callback(null); + } + }); + return; + } + callback(null); + } + ], + + /** + * Final clause which is executed after everything else or when an error is detected. + * An error is thrown if there is a problem which should be caught by a try statement. + */ + (err) => { + if (err) { + throw new Error(err); + } else if (Object.keys(toUpdate).length !== 0) { + /** + * Log any changes made. + */ + let updateString = ''; + if (config.databaseUpdateWrite) { + updateString = dbName + ' _id ' + existingDevice._id + ' (' + existingDevice.ClientID + ') updated: ' + + JSON.stringify(toUpdate); + } else { + updateString = dbName + ' _id ' + existingDevice._id + ' (' + existingDevice.ClientID + + ') update test (not written): ' + JSON.stringify(toUpdate); + } + log.system( + 'INFO', + updateString, + 'mainDB.updateDeviceCollection', + '', + 'System', + '127.0.0.1'); + } + }); + }); +} + +/** + * This scan checks the TransactionHistory collection and updates where appropriate. + * Note that this update process does not change LastUpdate. This is deliberate. + * + * @type {function} updateTransactionHistoryCollection + * @param {!object} transactionHistoryCollection - The collection to be updated. + * @param {!object} transactionCollection - Transaction collection that this history references for cross reference purposes. + * @param {!string} dbName - The database name for differentiation and the log files. + * + * Cyclomatic complexity error disabled. + */ +function updateTransactionHistoryCollection(transactionHistoryCollection, transactionCollection, dbName) { + /** + * Check each entry. This is potentially a long scan. + */ + transactionHistoryCollection.find().forEach((existingTransactionHistory) => { + const toUpdate = {}; + let existingTransaction; + async.series([ + function(callback) { + /** + * Pull the associated transaction to check integrity. + */ + transactionCollection.findOne({_id: mongodb.ObjectID(existingTransactionHistory.TransactionID)}, + (err, resultTransaction) => { + if (err) { + throw new Error(err); + } + existingTransaction = resultTransaction; + callback(null); + }); + }, + function(callback) { + /** + * Firstly, run integrity checks and flag any problems with the record. + * Integrity checks will not run until the account has been upgraded. + */ + if ((config.databaseIntegrityCheck) && + ('Integrity' in existingTransactionHistory) && + (config.CCServerVersion === '7.2.815')) { + /** + * Check the integrity. + */ + const integrityResult = []; + if (Object.keys(existingTransactionHistory).length !== 15) { + integrityResult.push('Incorrect number of keys; 15 expected, ' + + Object.keys(existingTransactionHistory).length + ' found.'); + } + + /** + * If there is a transaction then quickly analyse it. + */ + if (!existingTransaction) { + integrityResult.push('Orphaned TransactionHistory item - TransactionID does not exist.'); + } else { + /** + * Amount and time check. + */ + if (existingTransactionHistory.TotalAmount !== existingTransaction.TotalAmount) { + integrityResult.push('Total amount mismatch (_id ' + existingTransactionHistory.TransactionID + ').'); + } + + /** + * Check the there is a SaleTime match. + */ + if (existingTransaction.TransactionStatus === utils.TransactionComplete) { + if (existingTransactionHistory.SaleTime.toISOString() !== existingTransaction.SaleTime.toISOString()) { + integrityResult.push('SaleTime mismatch (_id ' + existingTransactionHistory.TransactionID + ').'); + } + } else if (existingTransaction.TransactionStatus === utils.TransactionRefunded) { + if (((existingTransactionHistory.TransactionType === utils.HistoryCustOutgoing) || + (existingTransactionHistory.TransactionType === utils.HistoryMerchIncoming)) && + (existingTransactionHistory.SaleTime.toISOString() !== existingTransaction.SaleTime.toISOString())) { + integrityResult.push('SaleTime mismatch (_id ' + existingTransactionHistory.TransactionID + ').'); + } + } else { + integrityResult.push('Invalid TransactionStatus in TransactionID (_id ' + + existingTransactionHistory.TransactionID + ').'); + } + + /** + * Check that the TransactionHistory detail matches the Transaction. + */ + switch (existingTransactionHistory.TransactionType) { + case utils.HistoryCustOutgoing: + case utils.HistoryCustRefund: + if (existingTransactionHistory.ClientID !== existingTransaction.CustomerClientID) { + integrityResult.push(existingTransactionHistory.ClientID + + ' is not the customer (_id ' + existingTransactionHistory.TransactionID + ').'); + } + if (existingTransactionHistory.OtherImage !== existingTransaction.MerchantImage) { + integrityResult.push('OtherImage is not the MerchantImage (_id ' + + existingTransactionHistory.TransactionID + ').'); + } + if (existingTransactionHistory.AccountID !== existingTransaction.CustomerAccountID) { + integrityResult.push('AccountID is not the CustomerAccountID (_id ' + + existingTransactionHistory.TransactionID + ').'); + } + break; + case utils.HistoryMerchIncoming: + case utils.HistoryMerchRefund: + if (existingTransactionHistory.ClientID !== existingTransaction.MerchantClientID) { + integrityResult.push(existingTransactionHistory.ClientID + + ' is not the merchant (_id ' + existingTransactionHistory.TransactionID + ').'); + } + if (existingTransactionHistory.OtherImage !== existingTransaction.CustomerImage) { + integrityResult.push('OtherImage is not the CustomerImage (_id ' + + existingTransactionHistory.TransactionID + ').'); + } + if (existingTransactionHistory.AccountID !== existingTransaction.MerchantAccountID) { + integrityResult.push('AccountID is not the MerchantAccountID (_id ' + + existingTransactionHistory.TransactionID + ').'); + } + break; + default: + integrityResult.push('Unknown TransactionType (_id ' + + existingTransactionHistory.TransactionID + ').'); + break; + } + } + + /** + * Check for any integrity results and append. + */ + if (integrityResult.length !== 0) { + /** + * If there has been no change in the errors since last time, don't update but do log. + */ + if ((existingTransactionHistory.Integrity !== null) && + (_.isEqual(integrityResult.sort(), existingTransactionHistory.Integrity.sort()))) { + log.system( + 'INFO', + (dbName + ' _id ' + existingTransactionHistory._id + ' (' + existingTransactionHistory.ClientID + + ') existing problem unchanged: {"Integrity":' + JSON.stringify(integrityResult) + '}'), + 'mainDB.updateTransactionHistoryCollection', + '', + 'System', + '127.0.0.1'); + } else { + toUpdate.Integrity = integrityResult; + } + } else if (existingTransactionHistory.Integrity !== null) { + /** + * Don't update if it's the same and there are no problems. + */ + toUpdate.Integrity = null; + } + } + callback(null); + }, + function(callback) { + /** + * Alpha 2 release upgrade path from Alpha 1. Protected by version number to prevent accidental changes. + */ + if (config.CCServerVersion === '7.2.815') { + if (!('APIVersion' in existingTransactionHistory)) { + toUpdate.APIVersion = config.CCServerVersion; + } + if (!('Integrity' in existingTransactionHistory)) { + toUpdate.Integrity = ['Integrity not verified.']; + } + if (!('MyLocation' in existingTransactionHistory)) { + /** + * Fill in depending on TransactionType: + */ + switch (existingTransactionHistory.TransactionType) { + case utils.HistoryCustOutgoing: + toUpdate.MyLocation = existingTransaction.CustomerLocation; + break; + case utils.HistoryMerchIncoming: + toUpdate.MyLocation = existingTransaction.MerchantLocation; + break; + default: + toUpdate.MyLocation = null; + break; + } + } else { + const newMyLocation = updateCoordinates(existingTransactionHistory.MyLocation); + if (newMyLocation !== false) { + toUpdate.MyLocation = newMyLocation; + } + } + } + callback(null); + }, + function(callback) { + /** + * Update only if there are changes. + */ + if ((Object.keys(toUpdate).length !== 0) && (config.databaseUpdateWrite)) { + updateObject(transactionHistoryCollection, {_id: mongodb.ObjectID(existingTransactionHistory._id)}, { + $set: toUpdate + }, + {upsert: false}, false, (err) => { + if (err) { + return callback(err); + } else { + return callback(null); + } + }); + return; + } + callback(null); + } + ], + + /** + * Final clause which is executed after everything else or when an error is detected. + * An error is thrown if there is a problem which should be caught by a try statement. + */ + (err) => { + if (err) { + throw new Error(err); + } else if (Object.keys(toUpdate).length !== 0) { + /** + * Log any changes made. + */ + let updateString = ''; + if (config.databaseUpdateWrite) { + updateString = dbName + ' _id ' + existingTransactionHistory._id + ' (' + + existingTransactionHistory.ClientID + ') updated: ' + JSON.stringify(toUpdate); + } else { + updateString = dbName + ' _id ' + existingTransactionHistory._id + ' (' + + existingTransactionHistory.ClientID + ') update test (not written): ' + JSON.stringify(toUpdate); + } + log.system( + 'INFO', + updateString, + 'mainDB.updateTransactionHistoryCollection', + '', + 'System', + '127.0.0.1'); + } + }); + }); +} + +/** + * This scan checks the Transaction collection and updates where appropriate. + * Note that this update process does not change LastUpdate. This is deliberate. + * + * @type {function} updateTransactionCollection + * @param {!object} transactionCollection - The collection to be updated. + * @param {!object} transactionHistoryCollection - Transaction history collection for cross reference purposes. + * @param {!object} accountCollection - Account collection for cross reference purposes. + * @param {!object} imagesCollection - Image collection for cross reference purposes. + * @param {!object} transactionArchiveCollection - Transaction archive to where old files are stored. + * @param {!string} dbName - The database name for differentiation and the log files. + * + * Cyclomatic complexity error disabled. + */ +function updateTransactionCollection(transactionCollection, transactionHistoryCollection, accountCollection, + imagesCollection, transactionArchiveCollection, dbName) { + /** + * Check each entry. This is potentially a long scan. + */ + transactionCollection.find().forEach((existingTransaction) => { + const toUpdate = {}; + let existingTransactionHistory = null; + let existingCustomerAccount = null; + let existingMerchantAccount = null; + let existingCustomerImage = null; + let existingMerchantImage = null; + async.series([ + function(callback) { + /** + * Pull the associated transaction history items to check integrity. + */ + transactionHistoryCollection.find({TransactionID: existingTransaction._id.toString()}).toArray( + (err, resultTransactionHistory) => { + if (err) { + throw new Error(err); + } + existingTransactionHistory = resultTransactionHistory; + callback(null); + }); + }, + function(callback) { + /** + * Pull the Customer account. + */ + if (existingTransaction.CustomerAccountID !== '') { + accountCollection.findOne({_id: mongodb.ObjectID(existingTransaction.CustomerAccountID)}, + (err, resultCustomerAccount) => { + if (err) { + throw new Error(err); + } + existingCustomerAccount = resultCustomerAccount; + return callback(null); + }); + } else { + return callback(null); + } + }, + function(callback) { + /** + * Pull the Merchant account. + */ + if (existingTransaction.MerchantAccountID !== '') { + accountCollection.findOne({_id: mongodb.ObjectID(existingTransaction.MerchantAccountID)}, + (err, resultMerchantAccount) => { + if (err) { + throw new Error(err); + } + existingMerchantAccount = resultMerchantAccount; + return callback(null); + }); + } else { + return callback(null); + } + }, + function(callback) { + /** + * Pull the Customer image. + */ + if (existingTransaction.CustomerImage !== '') { + imagesCollection.findOne({_id: mongodb.ObjectID(existingTransaction.CustomerImage)}, + (err, resultCustomerImage) => { + if (err) { + throw new Error(err); + } + existingCustomerImage = resultCustomerImage; + return callback(null); + }); + } else { + return callback(null); + } + }, + function(callback) { + /** + * Pull the Merchant image. + */ + if (existingTransaction.MerchantImage !== '') { + imagesCollection.findOne({_id: mongodb.ObjectID(existingTransaction.MerchantImage)}, + (err, resultMerchantImage) => { + if (err) { + throw new Error(err); + } + existingMerchantImage = resultMerchantImage; + return callback(null); + }); + } else { + return callback(null); + } + }, + function(callback) { + /** + * Firstly, run integrity checks and flag any problems with the record. + * Integrity checks will not run until the account has been upgraded. + */ + if ((config.databaseIntegrityCheck) && + ('Integrity' in existingTransaction) && + (config.CCServerVersion === '7.2.815')) { + /** + * Check the integrity. + */ + const integrityResult = []; + if (Object.keys(existingTransaction).length !== 50) { + integrityResult.push('Incorrect number of keys; 50 expected, ' + + Object.keys(existingTransaction).length + ' found.'); + } + + /** + * Check only transaction Complete and Refunded transactions. + * Firstly, checks valid for both. + */ + if ((existingTransaction.TransactionStatus === utils.TransactionComplete) || + (existingTransaction.TransactionStatus === utils.TransactionRefunded)) { + if (!existingCustomerAccount) { + integrityResult.push('Customer account cannot be found.'); + } else if (existingCustomerAccount.ClientID !== existingTransaction.CustomerClientID) { + integrityResult.push('Customer account does not have expected ClientID.'); + } + if (!existingMerchantAccount) { + integrityResult.push('Merchant account cannot be found.'); + } else if (existingMerchantAccount.ClientID !== existingTransaction.MerchantClientID) { + integrityResult.push('Merchant account does not have expected ClientID.'); + } + if (!existingCustomerImage) { + integrityResult.push('Invalid Customer image reference.'); + } else if ((existingCustomerImage.ImageType !== 'defaultSelfie') && + (existingCustomerImage.ImageType !== 'defaultCompanyLogo0')) { + if (existingCustomerImage.ClientID !== existingTransaction.CustomerClientID) { + integrityResult.push('Customer image in transaction is not owned by the same ClientID.'); + } + } + if (!existingMerchantImage) { + integrityResult.push('Invalid Merchant image reference.'); + } else if ((existingMerchantImage.ImageType !== 'defaultSelfie') && + (existingMerchantImage.ImageType !== 'defaultCompanyLogo0')) { + if (existingMerchantImage.ClientID !== existingTransaction.MerchantClientID) { + integrityResult.push('Merchant image in transaction is not owned by the same ClientID.'); + } + } + const total = existingTransaction.RequestAmount + existingTransaction.TipAmount - existingTransaction.PromoAmount; + if (existingTransaction.TotalAmount !== total) { + integrityResult.push('(RequestAmount + TipAmount - PromoAmount) is not equal to TotalAmount'); + } + } + + /** + * Checks valid in specific cases only. + */ + if (existingTransaction.TransactionStatus === utils.TransactionComplete) { + if (!existingTransactionHistory) { + integrityResult.push('Orphaned transaction - no transaction history items for complete transaction.'); + } else if (existingTransactionHistory.length !== 2) { + integrityResult.push('Complete transaction with an unexpected number of history items (NE 2).'); + } + if (existingTransaction.AmountRefunded !== 0) { + integrityResult.push('Unexpected refunds on a Complete transaction.'); + } + } else if (existingTransaction.TransactionStatus === utils.TransactionRefunded) { + if (!existingTransactionHistory) { + integrityResult.push('Orphaned transaction - no transaction history items for refunded transaction.'); + } else if (existingTransactionHistory.length !== 4) { + integrityResult.push('Refunded transaction with an unexpected number of history items (NE 4).'); + } + if (existingTransaction.TotalAmount !== existingTransaction.AmountRefunded) { + integrityResult.push('TotalAmount is not equal to the AmountRefunded for a fully refunded transaction.'); + } + } + + /** + * Check for any integrity results and append. + */ + if (integrityResult.length !== 0) { + /** + * If there has been no change in the errors since last time, don't update but do log. + */ + let userNames = ''; + if (existingTransaction.MerchantClientID !== '') { + userNames += existingTransaction.CustomerClientID + '/' + existingTransaction.MerchantClientID; + } else { + userNames += existingTransaction.CustomerClientID + '/[No Merchant]'; + } + if ((existingTransaction.Integrity !== null) && + (_.isEqual(integrityResult.sort(), existingTransaction.Integrity.sort()))) { + log.system( + 'INFO', + (dbName + ' _id ' + existingTransaction._id + ' (' + userNames + + ') existing problem unchanged: {"Integrity":' + JSON.stringify(integrityResult) + '}'), + 'mainDB.updateTransactionCollection', + '', + 'System', + '127.0.0.1'); + } else { + toUpdate.Integrity = integrityResult; + } + } else if (existingTransaction.Integrity !== null) { + /** + * Don't update if it's the same and there are no problems. + */ + toUpdate.Integrity = null; + } + } + return callback(null); + }, + function(callback) { + /** + * Alpha 2 release upgrade path from Alpha 1. Protected by version number to prevent accidental changes. + */ + if (config.CCServerVersion === '7.2.815') { + if (!('APIVersion' in existingTransaction)) { + toUpdate.APIVersion = config.CCServerVersion; + } + if (!('Integrity' in existingTransaction)) { + toUpdate.Integrity = ['Integrity not verified.']; + } + if (!('PayCodeExpiry' in existingTransaction)) { + toUpdate.PayCodeExpiry = new Date(0); + } + if (!('CustomerVATNo' in existingTransaction)) { + toUpdate.CustomerVATNo = null; + } + if (!('MerchantVATNo' in existingTransaction)) { + toUpdate.MerchantVATNo = null; + } + if ('CustomerLocation' in existingTransaction) { + const newCustomerLocation = updateCoordinates(existingTransaction.CustomerLocation); + if (newCustomerLocation !== false) { + toUpdate.CustomerLocation = newCustomerLocation; + } + } + if ('MerchantLocation' in existingTransaction) { + const newMerchantLocation = updateCoordinates(existingTransaction.MerchantLocation); + if (newMerchantLocation !== false) { + toUpdate.MerchantLocation = newMerchantLocation; + } + } + if (!('MerchantComment' in existingTransaction) || + existingTransaction.MerchantComment === null) { + toUpdate.MerchantComment = ''; + } + } + return callback(null); + }, + function(callback) { + /** + * Update only if there are changes and update is enabled. + */ + if ((Object.keys(toUpdate).length !== 0) && (config.databaseUpdateWrite)) { + updateObject(transactionCollection, {_id: mongodb.ObjectID(existingTransaction._id)}, { + $set: toUpdate + }, + {upsert: false}, false, (err) => { + if (err) { + return callback(err); + } else { + return callback(null); + } + }); + return; + } + callback(null); + }, + function(callback) { + /** + * If the transaction status is 0, 10 or 17 and the PayCode has expired then copy the Transaction to the + * TransactionArchive. + */ + if ((Object.keys(toUpdate).length === 0) && (config.databaseArchiveTransactions)) { + const newLastUpdate = new Date(); + if ((newLastUpdate > existingTransaction.PayCodeExpiry) && + ((existingTransaction.TransactionStatus === 0) || + (existingTransaction.TransactionStatus === 10) || + (existingTransaction.TransactionStatus === 17))) { + /** + * Store original TransactionID. + */ + const TransactionId = existingTransaction._id; + existingTransaction.TransactionID = existingTransaction._id.toString(); + delete existingTransaction._id; + existingTransaction.LastUpdate = newLastUpdate; + + /** + * Back up existing Transaction. + */ + addObject(transactionArchiveCollection, existingTransaction, undefined, false, (err) => { + if (err) { + callback(err); + return; + } + + /** + * Transaction added to archive. Delete from Transactions. + */ + removeObject(transactionCollection, {_id: TransactionId}, undefined, false, (error) => { + if (error) { + callback(error); + return; + } + + /** + * Old Transaction removed from database. + */ + log.system( + 'INFO', + (dbName + ' _id ' + TransactionId + ' (TransactionStatus: ' + + existingTransaction.TransactionStatus + ') has been archived.'), + 'mainDB.updateTransactionCollection', + '', + 'System', + '127.0.0.1'); + callback(null); + }); + }); + return; + } + } + callback(null); + } + ], + + /** + * Final clause which is executed after everything else or when an error is detected. + * An error is thrown if there is a problem which should be caught by a try statement. + */ + (err) => { + if (err) { + throw new Error(err); + } else if (Object.keys(toUpdate).length !== 0) { + /** + * Log any changes made. + * Archivals are logged in the last function above. + */ + let updateString = ''; + if (config.databaseUpdateWrite) { + updateString = dbName + ' _id ' + existingTransaction._id + ' (' + existingTransaction.CustomerClientID; + if (existingTransaction.MerchantClientID !== '') { + updateString += '/' + existingTransaction.MerchantClientID + + ') updated: ' + JSON.stringify(toUpdate); + } else { + updateString += '/[No Merchant]) updated: ' + JSON.stringify(toUpdate); + } + } else { + updateString = dbName + ' _id ' + existingTransaction._id + ' (' + existingTransaction.CustomerClientID; + if (existingTransaction.MerchantClientID !== '') { + updateString += '/' + existingTransaction.MerchantClientID + + ') update test (not written): ' + JSON.stringify(toUpdate); + } else { + updateString += '/[No Merchant]) update test (not written): ' + JSON.stringify(toUpdate); + } + } + log.system( + 'INFO', + updateString, + 'mainDB.updateTransactionCollection', + '', + 'System', + '127.0.0.1'); + } + }); + }); +} + +/** + * This is a startup scan that goes through the database and performs the appropriate actions + * depending on the settings. + */ +function updateDatabase() { + log.system( + 'STARTUP', + 'Database update enabled. Processing data...', + 'mainDB.updateDatabase', + '', + 'System', + '127.0.0.1'); + + /** + * Scan each collection. + */ + try { + updateAccountCollection(exports.collectionAccount, exports.collectionAccountArchive, + exports.collectionAddresses, 'Account'); + updateClientCollection(exports.collectionClient, exports.collectionAddresses, 'Client'); + updateClientCollection(exports.collectionClientArchive, exports.collectionAddressArchive, 'ClientArchive'); + updateDeviceCollection(exports.collectionDevice, 'Device'); + updateDeviceCollection(exports.collectionDeviceArchive, 'DeviceArchive'); + updateTransactionCollection(exports.collectionTransaction, exports.collectionTransactionHistory, + exports.collectionAccount, exports.collectionImages, exports.collectionTransactionArchive, 'Transaction'); + updateTransactionHistoryCollection(exports.collectionTransactionHistory, exports.collectionTransaction, + 'TransactionHistory'); + } catch (error) { + log.system( + 'ERROR', + ('Database update failed: ' + error), + 'mainDB.updateDatabase', + '', + 'System', + '127.0.0.1'); + } +} diff --git a/node_server/ComServe/migrations.js b/node_server/ComServe/migrations.js new file mode 100644 index 0000000..94356cb --- /dev/null +++ b/node_server/ComServe/migrations.js @@ -0,0 +1,314 @@ +/** + * @fileOverview Node.js Bridge Server data migrations functions + */ + +'use strict'; + +var Q = require('q'); +var _ = require('lodash'); +var mongodb = require('mongodb'); +var log = require(global.pathPrefix + 'log.js'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +module.exports = { + migrateClientNameToID: migrateClientNameToID +}; + +/** + * This function migrates the data such that the ClientName (i.e. email address) + * is no longer the foreign key in the related tables. This is changed to use + * the ClientID instead. + */ +function migrateClientNameToID() { + /** + * Find all the Clients without a ClientID, and give them one + */ + var query = { + ClientID: {$exists: false} + }; + var projection = { + _id: 1 + }; + + var deferAddComplete = Q.defer(); + var addClientIdPromises = []; + + mainDB.collectionClient.find(query) + .project(projection) + .forEach( + addIdToClient.bind(null, addClientIdPromises), + onAddIdDone.bind(null, deferAddComplete) + ); + + /** + * Wait for the iteration to complete, + * then wait for the updates to all complete, + * then start doing updates for all clients + */ + deferAddComplete.promise.then(function() { + Q.all(addClientIdPromises).then(function() { + doMigration(); + }); + }); +} + +/** + * Called for all clients that don't have an id. + * Used to add a unique id, then add all the other fields + * + * @param {Promise[]} promisesArray - array of promises to add our update to + * @param {Object} client - the value from the database + */ +function addIdToClient(promisesArray, client) { + var query = { + _id: client._id + }; + var randomId = utils.timeBasedRandomCode(); + var update = { + $set: { + ClientID: randomId + } + }; + promisesArray.push( + mainDB.collectionClient.updateOne( + query, + update + )); +} + +/** + * Called when the foreach iterator has gone through every client that needs + * an ID added + * + * @param {Defer} defer - a deffered promise for the completion of the foreach + * @param {any} err - any errors while doing the foreach + */ +function onAddIdDone(defer, err) { + if (err) { + log.system( + 'CRITICAL', + 'failed to iterate all clients needing ids', + 'migrations.migrateClientNameToID', + '', + 'System', + '127.0.0.1'); + defer.reject(err); + } else { + // All passed + defer.resolve(); + } +} + +function doMigration() { + log.system( + 'INFO', + 'Starting migration for ClientID', + 'migrations.doMigration', + '', + 'System', + '127.0.0.1'); + /** + * Find all the Clients and update all the related tables + */ + var query = { + ClientID: {$exists: true} + }; + var projection = { + ClientName: 1, + ClientID: 1 + }; + + var deferUpdateComplete = Q.defer(); + var updatePromises = []; + + /** + * List of collections to change. Format is either: + * {String} - Name of the collection, with default ClientName -> ClientID conversion + * {String[]} - Name of collection, Name of existing Email field, Name of new ID field + */ + const collectionsToChange = [ + 'Account', + 'AccountArchive', + 'Addresses', + 'AddressArchive', + 'BridgeLogin', + 'Device', + 'DeviceArchive', + 'Images', + 'Items', + 'Messages', + 'MessagesArchive', + 'PayCode', + ['Transaction', 'CustomerClientName', 'CustomerClientID'], + ['Transaction', 'MerchantClientName', 'MerchantClientID'], + ['TransactionArchive', 'CustomerClientName', 'CustomerClientID'], + ['TransactionArchive', 'MerchantClientName', 'MerchantClientID'], + 'TransactionHistory' + ]; + + /** + * Create bulk operations for all collections + */ + var ops = createBulkOps(collectionsToChange); + log.system( + 'INFO', + 'Created [' + Object.keys(ops).length + '] bulk operations', + 'migrations.doMigration', + '', + 'System', + '127.0.0.1'); + + mainDB.collectionClient.find(query) + .project(projection) + .forEach( + createClientOps.bind(null, collectionsToChange, ops), + runOps.bind(null,ops) + ); + +} + +/** + * Create the bulk operations that we will update with all the changes we need + * to do. + * + * @param {(String|String[])[]} collectionsToChange - the list of collections to chnage + * + * @returns {Object} - object with key = collection name, value = UnorderedBulkOperation + */ +function createBulkOps(collectionsToChange) { + log.system( + 'INFO', + 'Initializing [' + collectionsToChange.length + '] bulk operations', + 'migrations.createBulkOps', + '', + 'System', + '127.0.0.1'); + + var ops = {}; + + for (var i = 0; i < collectionsToChange.length; ++i) { + var collName = collectionsToChange[i]; + if (_.isArray(collName)) { + collName = collName[0]; + } + + var collection = mainDB['collection' + collName]; + var bulkOp = collection.initializeUnorderedBulkOp(); + ops[collName] = bulkOp; + + log.system( + 'INFO', + ' - initialized bulk op for [' + collName + ']', + 'migrations.createBulkOps', + '', + 'System', + '127.0.0.1'); + } + return ops; +} + +/** + * Creates all the bulk operations + * + * @param {(String|String[])[]} collectionsToChange - the list of collections to change + * @param {UnorderedBulkOperation[]} ops - the list of operations to add to + * @param {Object} client - the client to create ops for + */ +function createClientOps(collectionsToChange, ops, client) { + log.system( + 'INFO', + 'Creating update ops for [' + client.ClientName + '] -> [' + client.ClientID + ']', + 'migrations.createClientOps', + '', + 'System', + '127.0.0.1'); + + for (var i = 0; i < collectionsToChange.length; ++i) { + var collName = collectionsToChange[i]; + var srcName = 'ClientName'; + var destName = 'ClientID'; + if (_.isArray(collName)) { + collName = collectionsToChange[i][0]; + srcName = collectionsToChange[i][1]; + destName = collectionsToChange[i][2]; + } + + // Find all records that have the specified ClientName + var query = {}; + query[srcName] = client.ClientName; + + // Update with the relevant ClientID, and remove the ClientName + var update = { + $set: {}, + $unset: {} + }; + update.$unset[srcName] = ''; + update.$set[destName] = client.ClientID; + ops[collName].find(query).update(update); + } +} + +/** + * Run the update operations + * + * @param {UnorderedBulkOperation[]} ops - the list of operations to add to + * @param {varies} err - any errors + */ +function runOps(ops, err) { + if (err) { + log.system( + 'CRITICAL', + 'failed to create all bulk operations', + 'migrations.runOps', + '', + 'System', + '127.0.0.1'); + return; + } + var opsPromises = []; + + log.system( + 'INFO', + 'Executing [' + Object.keys(ops).length + '] bulk operation', + 'migrations.runOps', + '', + 'System', + '127.0.0.1'); + /** + * Execute all the operations + */ + _.forEach(ops, function(val, key) { + log.system( + 'INFO', + ' - executing bulk operation for: ' + key, + 'migrations.runOps', + '', + 'System', + '127.0.0.1'); + opsPromises.push(val.execute({ + fsync: true + })); + }); + + Q.all(opsPromises) + .then(function() { + log.system( + 'CRITICAL', + 'Migration to ClientID complete successfully!', + 'migrations.runOps', + '', + 'System', + '127.0.0.1'); + }) + .catch(function(err) { + log.system( + 'CRITICAL', + 'failed to run all update operations', + 'migrations.runOps', + '', + 'System', + '127.0.0.1'); + return Q.reject(err); + }); +} diff --git a/node_server/ComServe/rate_limit.js b/node_server/ComServe/rate_limit.js new file mode 100644 index 0000000..9c60bd3 --- /dev/null +++ b/node_server/ComServe/rate_limit.js @@ -0,0 +1,99 @@ +/** + * @fileOverview Rate limit functions for the app API + * @preserve Copyright 2016 Comcarde Ltd. + * @author Richard Taylor + * @see #bridge_server-core + * + * Defines rate limits for the app API, and a default rate limit for any other + * requests. + */ +var _ = require('lodash'); +var RateLimit = require('express-rate-limit'); +var config = require(global.configFile); + +/** + * Mobiles are frequently behind NAT, so we combine IP address + port number + * to give a good approximation of who a remote connection is related to. + * Note that the firewalls all seem to populate different information, the remotePort + * being the most difficult to verify. Therefore the rate limiter will only work correctly + * if the connection is deemed secure. + * + * @param {object} req - The request object + * + * @returns {string} - The key to use to identify this client + */ +function rateLimitByIpAndPort(req) { + if (req.secure) { + /** + * If the request is coming from a secure source, we can trust the headers. + * Azure and Bluemix are special cases as the info is put elsewhere. + */ + switch (global.CURRENT_DEPLOYMENT_ENV) { + case 'Azure': + if (req.headers.hasOwnProperty('x-forwarded-for')) { + return (req.headers['x-forwarded-for'].split(':')[0] + '-' + req.headers['x-forwarded-for'].split(':')[1]); + } + break; + case 'Bluemix': + if (req.headers.hasOwnProperty('$wsra')) { + return (req.headers.$wsra + '-' + req.connection.remotePort); + } + break; + default: + break; + } + } + + /** + * The request is not secure or no headers are present. The default action should be used. + * This may result in limiting problems in new environments if the configuration requires + * a special case as shown above. + */ + return (req.ip + '-' + req.connection.remotePort); +} + +/* + * Rate limiting for the app API + * Warning: we must clone the value from config so that when we change the + * keyGenerator etc. it doesn't affect other places using the same + * config. + */ +var rateLimitConfig = _.clone(config.rateLimits.api); +rateLimitConfig.keyGenerator = rateLimitByIpAndPort; +rateLimitConfig.handler = jsonResponse; +var limiterApi = new RateLimit(rateLimitConfig); + +/** + * Sends a JSON response if the limit is reached + * + * @param {Object} req - The request object + * @param {Object} res - The response object + * + */ +function jsonResponse(req, res) { + res.status(rateLimitConfig.statusCode).json({ + code: 465, + info: 'Rate limit reached. Please wait and try again.' + }); +} + +/* + * Rate limiting for everything else + * Warning: we must clone the value from config so that when we change the + * keyGenerator etc. it doesn't affect other places using the same + * config. + */ +var rateLimitConfigDefault = _.clone(config.rateLimits.fallback); +rateLimitConfigDefault.keyGenerator = rateLimitByIpAndPort; +var limiterDefault = new RateLimit(rateLimitConfigDefault); + +/** + * Function to insert the rate limiting middleware into the chain for the + * appropriate paths + * + * @param {object} server - The https server to connect the middleware to + */ +exports.enableLimits = function(server) { + server.use(/\/server_post/i, limiterApi); + server.use(/\/(?!server_post).*/i, limiterDefault); // Everything except /server_post +}; diff --git a/node_server/ComServe/sms-promises.js b/node_server/ComServe/sms-promises.js new file mode 100644 index 0000000..840b762 --- /dev/null +++ b/node_server/ComServe/sms-promises.js @@ -0,0 +1,11 @@ +/** + * @file This file wraps the functions in mailer.js with promises for simpler + * use in promises and async/await + */ + +const Q = require('q'); +const sms = require('./sms.js'); + +module.exports = { + sendSMS: (...args) => Q.nfapply(sms.sendSMS, args) +}; diff --git a/node_server/ComServe/sms.js b/node_server/ComServe/sms.js new file mode 100644 index 0000000..d9081ff --- /dev/null +++ b/node_server/ComServe/sms.js @@ -0,0 +1,121 @@ +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Comcarde Node.js SMS Compatibility with Textlocal. +// Provides -Bridge- pay functionality. +// Copyright 2014 Comcarde +// Written by Keith Symington +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Includes +var https = require('https'); +var querystring = require('querystring'); +var log = require(global.pathPrefix + 'log.js'); +var adminNotifier = require(global.pathPrefix + '../utils/adminNotifier.js'); + +// SMS setup code. +exports.smsPart1 = '/send/?username=admin@comcarde.com&hash=0d0781473c9df47b3cce91d5f71d9d958eed6e3a&numbers='; +exports.smsPart2 = '&message='; +exports.smsPart3 = '&sender=Bridge'; +exports.smsTest = '&test=true'; +exports.smsTestMode = false; +exports.smsCredits = -1; +exports.adminMobile = '07713904702'; // Keith Symington +exports.backupMobile = '07789191413'; // Tom Mathews + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Sends an SMS message to defined number using a TextLocal account. +// Uses SSL. The function needs to be passed a callback. The callback +// will be given two arguments: the first is an error, the second +// is the number of texts remaining in the balance. +// This function is silent and does not log to console. +// The system should automatically add escape characters (%20) - +// if this fails the error 'No sender name was specified' will be returned via callback. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +exports.sendSMS = function(mode, number, message, next) { + // Local variables. + var fullQuery = ''; + var returnedData = ''; + + // Check for test mode. + if (mode !== 'Test') { + // Check for available credits. + if (exports.smsCredits === 0) { + next('No SMS credits with Txtlocal.', 0); + } else { + // When in test mode, add the test identifier. + if (exports.smsTestMode) { + fullQuery = exports.smsPart1 + number + exports.smsPart2 + querystring.escape(message) + exports.smsPart3 + exports.smsTest; + } else { + fullQuery = exports.smsPart1 + number + exports.smsPart2 + querystring.escape(message) + exports.smsPart3; + } + + // Set up query. + var smsOptions = { + host: 'api.txtlocal.com', + port: 443, + path: fullQuery + }; + + // Initiate the get request. + https.get(smsOptions, function(result) { + if (result.statusCode !== 200) { + next('HTTPS error when trying to reach Textlocal servers.', null); + } else { + // Data indicates that information is still arriving. + result.on('data', function(chunk) { + returnedData += chunk; + }); + + // End indicates that everything has been read. + result.on('end', function() { // Try to parse the data to see if it is JSON. + var returnedJSON = JSON.parse(returnedData); + // Check the status of the SMS send. + if (returnedJSON.status !== 'success') { + // SMS send failed. + next(returnedJSON.status, returnedJSON.balance); + } else { + // SMS send successful. + exports.smsCredits = returnedJSON.balance; + // Warning text for low credit. Throw away callback. + if (exports.smsCredits === 10) { + exports.sendSMS(null, (exports.adminMobile + ',' + exports.backupMobile), + 'System Warning: Txtlocal SMS credits nearly exhausted.', function(err, smsBalance) { + // Check for errors. + if (err) { + log.system( + 'CRITICAL', + ('Cannot send SMS. ' + err), + 'sms.sendSMS', + '', + 'System', + '127.0.0.1'); + } else { + log.system( + 'ERROR', + ('Txtlocal SMS credits now exhausted (' + smsBalance + ').'), + 'sms.sendSMS', + '', + 'System', + '127.0.0.1'); + } + }); + } + + // + // Also notify via adminNotifier + // + adminNotifier.notifyCredits('txtlocal', returnedJSON.balance); + + // Success!!!! Send callback. + next(null, returnedJSON.balance); + } + }); + } + }).on('error', function(err) { + next(err, null); + }); + } + } else { + // Simply call back in test mode. + next(null, null); + } +}; diff --git a/node_server/ComServe/specs/mainDB-promises.spec.js b/node_server/ComServe/specs/mainDB-promises.spec.js new file mode 100644 index 0000000..e279f1c --- /dev/null +++ b/node_server/ComServe/specs/mainDB-promises.spec.js @@ -0,0 +1,178 @@ +/** + * Unit testing file for mainDB + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 5] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); +const httpStatus = require('http-status-codes'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const mainDBPromisesClass = rewire('../mainDB-promises.js'); +const maindDBStub = mainDBPromisesClass.__get__('mainDB'); +const sandbox = sinon.createSandbox(); +const expect = chai.expect; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +describe('mainDB', () => { + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + sandbox.restore(); + }); + describe('calls "checkObjectUpdated"', () => { + describe('which succeeds', () => { + let returnValue; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + sandbox.stub(maindDBStub, 'updateObject').callsArgWith(4, null, {result: {nModified: 1}}); + + returnValue = await mainDBPromisesClass.updateObjectPCheckObjectUpdated(1, 2, 3, 4, 5); + }); + it('called with correct params', () => { + return expect(maindDBStub.updateObject) + .calledOnce + .calledWith( + 1, 2, 3, 4 + ); + }); + it('it returns with the return value', () => { + return expect(returnValue).eql( + { + result: { + nModified: 1 + } + } + ); + }); + }); + describe('which fails', () => { + describe('general monoDB error', () => { + let errorValue; + + beforeEach(async () => { + sandbox.stub(maindDBStub, 'updateObject').callsArgWith(4, 'mongo error'); + + try { + await mainDBPromisesClass.updateObjectPCheckObjectUpdated(1, 2, 3, 4, 5); + } catch (error) { + errorValue = error; + } + }); + it('called with correct params', () => { + return expect(maindDBStub.updateObject) + .calledOnce + .calledWith( + 1, 2, 3, 4 + ); + }); + it('it returns with correct error code', () => { + return expect(errorValue).to.eql({ + code: 5, + message: 'Database offline.', + httpCode: httpStatus.BAD_GATEWAY + }); + }); + }); + describe('failed to update any objects', () => { + let errorValue; + + beforeEach(async () => { + sandbox.stub(maindDBStub, 'updateObject').callsArgWith(4, null, {result: {nModified: 0}}); + + try { + await mainDBPromisesClass.updateObjectPCheckObjectUpdated(1, 2, 3, 4, 5); + } catch (error) { + errorValue = error; + } + }); + it('called with correct params', () => { + return expect(maindDBStub.updateObject) + .calledOnce + .calledWith( + 1, 2, 3, 4 + ); + }); + it('it returns with correct error code', () => { + return expect(errorValue).to.eql({ + code: 5, + message: 'Failed to update object', + httpCode: httpStatus.CONFLICT + }); + }); + }); + }); + }); + describe('calls "withCode"', () => { + describe('which succeeds', () => { + let returnValue; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + sandbox.stub(mainDBPromisesClass, 'addObject').resolves({aValue: 5}); + + returnValue = await mainDBPromisesClass.addObjectPWithCode(1, 2, 3, 4, 5); + }); + it('called with correct params', () => { + return expect(mainDBPromisesClass.addObject) + .calledOnce + .calledWith( + 1, 2, 3, 4 + ); + }); + it('it returns with the return value', () => { + return expect(returnValue).to.eql( + {aValue: 5} + ); + }); + }); + describe('which fails', () => { + let errorValue; + + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + sandbox.stub(mainDBPromisesClass, 'addObject').rejects(); + + try { + await mainDBPromisesClass.addObjectPWithCode(1, 2, 3, 4, 5); + } catch (error) { + errorValue = error; + } + }); + it('called with correct params', () => { + return expect(mainDBPromisesClass.addObject) + .calledOnce + .calledWith( + 1, 2, 3, 4 + ); + }); + it('it returns with correct error code', () => { + return expect(errorValue).to.eql({ + code: 5, + message: 'Database offline.', + httpCode: httpStatus.BAD_GATEWAY + }); + }); + }); + }); +}); diff --git a/node_server/ComServe/specs/utils.spec.js b/node_server/ComServe/specs/utils.spec.js new file mode 100644 index 0000000..5bd4ad5 --- /dev/null +++ b/node_server/ComServe/specs/utils.spec.js @@ -0,0 +1,685 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable mocha/no-hooks-for-single-case */ +/* eslint-disable max-len*/ + +'use strict'; +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +// eslint-disable-next-line import/no-unassigned-import +require('../../tools/test/testGlobals'); + +const utils = rewire('../utils'); + +const expect = chai.expect; +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const DATA_STRING = 'someData'; +const KEY = '1'; +const CLIENT_ID = '123456789012345678901234'; +const INVALID_CLIENT_ID = '1234567890123456789012345'; +const DIFFERENT_CLIENT_ID = '123456789012345678900000'; +const ENCRYPTED_DATA = '3::9b357eaf82b6e86f8bfc8761a6fd2e3f6787b4e96f7e228388fb70a8e1d5b6f9a0f7ea03f3e9f19684a6c2cd97ebeba85d45c77c05c2f3b9adba878f84b86a95'; +const ENCRYPTED_DATA_BAD_FORMAT = '3117354dce8e95918cc6cd5ad7932d258b2dd9558e0a613a2825fe54d36528d22c58c9a75c4d62c8adabc73a345f31c899a9cf9b12ee66489cd9b71f29b1fa2cf81'; +const ENCRYPTED_DATA_WRONG_VERSION = '1::7354dce8e95918cc6cd5ad7932d258b2dd9558e0a613a2825fe54d36528d22c58c9a75c4d62c8adabc73a345f31c899a9cf9b12ee66489cd9b71f29b1fa2cf81'; + +describe('ComServe.utils', () => { + describe('calls encryptDataV3', () => { + it('returns a different value when encrypting identical data', () => { + const firstValue = utils.encryptDataV3(DATA_STRING, KEY, CLIENT_ID); + const secondValue = utils.encryptDataV3(DATA_STRING, KEY, CLIENT_ID); + return expect(firstValue).to.not.equal(secondValue); + }); + describe('returns with an error', () => { + it('Nothing to encrypt.', () => { + const returnValue = utils.encryptDataV3(null, KEY, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(1, 'Nothing to encrypt.')); + }); + it('No client key.', () => { + const returnValue = utils.encryptDataV3(DATA_STRING, null, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(2, 'No client key.')); + }); + it('No client ID.', () => { + const returnValue = utils.encryptDataV3(DATA_STRING, KEY, null); + return expect(returnValue).to.deep.equal(utils.createError(3, 'No client ID.')); + }); + it('Invalid client ID.', () => { + const returnValue = utils.encryptDataV3(DATA_STRING, KEY, INVALID_CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(3, 'Client Id length must be 24')); + }); + it('Data to encrypt must be a string.', () => { + const returnValue = utils.encryptDataV3({}, KEY, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(4, 'Data to encrypt must be a string.')); + }); + it('Client key must be a string.', () => { + const returnValue = utils.encryptDataV3(DATA_STRING, {}, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(5, 'Client key must be a string.')); + }); + it('Client Key must be in Hex', () => { + const returnValue = utils.encryptDataV3(DATA_STRING, 'z', CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(5, 'Client Key must be in Hex')); + }); + it('Client ID must be a string.', () => { + const returnValue = utils.encryptDataV3(DATA_STRING, KEY, {}); + return expect(returnValue).to.deep.equal(utils.createError(6, 'Client ID must be a string.')); + }); + }); + }); + describe('calls decryptDataV3', () => { + it('returns the correct decrypted data after encrypting the data', () => { + const decryptedData = utils.decryptDataV3(ENCRYPTED_DATA, KEY, CLIENT_ID); + return expect(decryptedData).to.equal(DATA_STRING); + }); + describe('returns with an error', () => { + it('Nothing to decrypt.', () => { + const returnValue = utils.decryptDataV3(null, KEY, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(1, 'Nothing to decrypt.')); + }); + it('No client key.', () => { + const returnValue = utils.decryptDataV3(ENCRYPTED_DATA, null, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(2, 'No client key.')); + }); + it('No client ID.', () => { + const returnValue = utils.decryptDataV3(ENCRYPTED_DATA, KEY, null); + return expect(returnValue).to.deep.equal(utils.createError(3, 'No client ID.')); + }); + it('Data to encrypt must be a string.', () => { + const returnValue = utils.decryptDataV3({}, KEY, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(4, 'Data to be decrypted must be a string.')); + }); + it('Client key must be a string.', () => { + const returnValue = utils.decryptDataV3(ENCRYPTED_DATA, {}, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(5, 'Client key must be a string.')); + }); + it('Client ID must be a string.', () => { + const returnValue = utils.decryptDataV3(ENCRYPTED_DATA, KEY, {}); + return expect(returnValue).to.deep.equal(utils.createError(6, 'Client ID must be a string.')); + }); + it('Encrypted data did not contain the 2 expected elements.', () => { + const returnValue = utils.decryptDataV3(ENCRYPTED_DATA_BAD_FORMAT, KEY, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(7, 'Encrypted data did not contain the 2 expected elements.')); + }); + it('Unexpected encryption version.', () => { + const returnValue = utils.decryptDataV3(ENCRYPTED_DATA_WRONG_VERSION, KEY, CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(8, 'Unexpected encryption version.')); + }); + it('Information does not belong to client.', () => { + const returnValue = utils.decryptDataV3(ENCRYPTED_DATA, KEY, DIFFERENT_CLIENT_ID); + return expect(returnValue).to.deep.equal(utils.createError(12, 'Information does not belong to client.')); + }); + }); + }); + describe('calls identifyCard', () => { + it('returns ISO / TC68 Card', () => { + const cardDetails = utils.identifyCard('0000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '0*** **** **** 0000', + type: 'ISO / TC68 Card', + icon: 'Generic-card.png' + }); + }); + it('returns Airline/UATP', () => { + const cardDetails = utils.identifyCard('1000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '1*** **** **** 0000', + type: 'Airline/UATP', + icon: 'Generic-card.png' + }); + }); + it('returns Diners Club enRoute, try 1', () => { + const cardDetails = utils.identifyCard('2014000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '2*** **** **** 0000', + type: 'Diners Club enRoute', + icon: 'Diners-Generic.png' + }); + }); + it('returns Diners Club enRoute, try 2', () => { + const cardDetails = utils.identifyCard('2149000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '2*** **** **** 0000', + type: 'Diners Club enRoute', + icon: 'Diners-Generic.png' + }); + }); + it('returns MIR, try 1', () => { + const cardDetails = utils.identifyCard('2204000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '2*** **** **** 0000', + type: 'MIR', + icon: 'MIR.png' + }); + }); + it('returns MIR, try 2', () => { + const cardDetails = utils.identifyCard('2200000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '2*** **** **** 0000', + type: 'MIR', + icon: 'MIR.png' + }); + }); + it('returns Mastercard, try 1', () => { + const cardDetails = utils.identifyCard('2720000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '2*** **** **** 0000', + type: 'Mastercard', + icon: 'MASTERCARD_CREDIT.png' + }); + }); + it('returns Mastercard, try 2', () => { + const cardDetails = utils.identifyCard('2221000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '2*** **** **** 0000', + type: 'Mastercard', + icon: 'MASTERCARD_CREDIT.png' + }); + }); + it('returns Airline/UATP/Other Card', () => { + const cardDetails = utils.identifyCard('2000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '2*** **** **** 0000', + type: 'Airline/UATP/Other Card', + icon: 'Generic-card.png' + }); + }); + it('returns American Express, try 1', () => { + const cardDetails = utils.identifyCard('3400000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'American Express', + icon: 'AMEX.png' + }); + }); + it('returns American Express, try 2', () => { + const cardDetails = utils.identifyCard('3700000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'American Express', + icon: 'AMEX.png' + }); + }); + it('returns Diners Club International, try 1', () => { + const cardDetails = utils.identifyCard('3600000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'Diners Club International', + icon: 'DINERS.png' + }); + }); + it('returns Diners Club International, try 2', () => { + const cardDetails = utils.identifyCard('3800000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'Diners Club International', + icon: 'DINERS.png' + }); + }); + it('returns Diners Club International, try 3', () => { + const cardDetails = utils.identifyCard('3900000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'Diners Club International', + icon: 'DINERS.png' + }); + }); + it('returns Diners Club International, try 4', () => { + const cardDetails = utils.identifyCard('3090000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'Diners Club International', + icon: 'DINERS.png' + }); + }); + it('returns Diners Club, try 1', () => { + const cardDetails = utils.identifyCard('3000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'Diners Club', + icon: 'DINERS.png' + }); + }); + it('returns Diners Club, try 2', () => { + const cardDetails = utils.identifyCard('3050000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'Diners Club', + icon: 'DINERS.png' + }); + }); + it('returns JCB. try 1', () => { + const cardDetails = utils.identifyCard('3589000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'JCB', + icon: 'JCB.png' + }); + }); + it('returns JCB. try 2', () => { + const cardDetails = utils.identifyCard('3528000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'JCB', + icon: 'JCB.png' + }); + }); + it('returns Other Card, try 1', () => { + const cardDetails = utils.identifyCard('3590000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '3*** **** **** 0000', + type: 'Other Card', + icon: 'Generic-card.png' + }); + }); + it('returns Dankort, try 1', () => { + const cardDetails = utils.identifyCard('4175000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '4*** **** **** 0000', + type: 'Dankort', + icon: 'Dankort.png' + }); + }); + it('returns Dankort, try 2', () => { + const cardDetails = utils.identifyCard('4571000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '4*** **** **** 0000', + type: 'Dankort', + icon: 'Dankort.png' + }); + }); + it('returns Maestro , try 1', () => { + const cardDetails = utils.identifyCard('4903000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '4*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Maestro , try 2', () => { + const cardDetails = utils.identifyCard('4905000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '4*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Maestro , try 3', () => { + const cardDetails = utils.identifyCard('4911000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '4*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Maestro , try 4', () => { + const cardDetails = utils.identifyCard('4936000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '4*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Visa', () => { + const cardDetails = utils.identifyCard('4000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '4*** **** **** 0000', + type: 'Visa', + icon: 'VISA_CREDIT.png' + }); + }); + it('returns MasterCard, try 1', () => { + const cardDetails = utils.identifyCard('5100000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'MasterCard', + icon: 'MASTERCARD_CREDIT.png' + }); + }); + it('returns MasterCard, try 2', () => { + const cardDetails = utils.identifyCard('5200000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'MasterCard', + icon: 'MASTERCARD_CREDIT.png' + }); + }); + it('returns MasterCard, try 3', () => { + const cardDetails = utils.identifyCard('5300000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'MasterCard', + icon: 'MASTERCARD_CREDIT.png' + }); + }); + it('returns MasterCard, try 4', () => { + const cardDetails = utils.identifyCard('5400000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'MasterCard', + icon: 'MASTERCARD_CREDIT.png' + }); + }); + it('returns MasterCard, try 5', () => { + const cardDetails = utils.identifyCard('5500000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'MasterCard', + icon: 'MASTERCARD_CREDIT.png' + }); + }); + it('returns Maestro, try 1', () => { + const cardDetails = utils.identifyCard('5000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Maestro, try 2', () => { + const cardDetails = utils.identifyCard('5600000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Maestro, try 3', () => { + const cardDetails = utils.identifyCard('5700000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Maestro, try 4', () => { + const cardDetails = utils.identifyCard('5800000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Dankort', () => { + const cardDetails = utils.identifyCard('5019000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Dankort', + icon: 'Dankort.png' + }); + }); + it('returns Bankcard, try 1', () => { + const cardDetails = utils.identifyCard('5610000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Bankcard', + icon: 'Generic-card.png' + }); + }); + it('returns CardGuard', () => { + const cardDetails = utils.identifyCard('5392000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'CardGuard', + icon: 'Generic-card.png' + }); + }); + it('returns Maestro, try 5', () => { + const cardDetails = utils.identifyCard('5641820000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Bankcard, try 2', () => { + const cardDetails = utils.identifyCard('5602210000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Bankcard', + icon: 'Generic-card.png' + }); + }); + it('returns Bankcard, try 3', () => { + const cardDetails = utils.identifyCard('5602250000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Bankcard', + icon: 'Generic-card.png' + }); + }); + it('returns Verve, try 1', () => { + const cardDetails = utils.identifyCard('5060990000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Verve', + icon: 'Generic-card.png' + }); + }); + it('returns Verve, try 2', () => { + const cardDetails = utils.identifyCard('5061980000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Verve', + icon: 'Generic-card.png' + }); + }); + it('returns Other Card, try 2', () => { + const cardDetails = utils.identifyCard('5900000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '5*** **** **** 0000', + type: 'Other Card', + icon: 'Generic-card.png' + }); + }); + it('returns China UnionPay', () => { + const cardDetails = utils.identifyCard('6200000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'China UnionPay', + icon: 'Generic-card.png' + }); + }); + it('returns Discover Card, try 1', () => { + const cardDetails = utils.identifyCard('6500000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Discover Card', + icon: 'Discover-card.png' + }); + }); + it('returns InstaPayment, try 1', () => { + const cardDetails = utils.identifyCard('6370000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'InstaPayment', + icon: 'Generic-card.png' + }); + }); + it('returns InstaPayment, try 2', () => { + const cardDetails = utils.identifyCard('6380000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'InstaPayment', + icon: 'Generic-card.png' + }); + }); + it('returns InstaPayment, try 3', () => { + const cardDetails = utils.identifyCard('6390000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'InstaPayment', + icon: 'Generic-card.png' + }); + }); + it('returns Discover Card. try 2', () => { + const cardDetails = utils.identifyCard('6440000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Discover Card', + icon: 'Discover-card.png' + }); + }); + it('returns Discover Card. try 3', () => { + const cardDetails = utils.identifyCard('6490000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Discover Card', + icon: 'Discover-card.png' + }); + }); + it('returns InterPayment Card', () => { + const cardDetails = utils.identifyCard('6360000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'InterPayment Card', + icon: 'Generic-card.png' + }); + }); + it('returns Discover Card. try 4', () => { + const cardDetails = utils.identifyCard('6011000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Discover Card', + icon: 'Discover-card.png' + }); + }); + it('returns Laser, try 1', () => { + const cardDetails = utils.identifyCard('6304000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Laser', + icon: 'Generic-card.png' + }); + }); + it('returns Laser, try 2', () => { + const cardDetails = utils.identifyCard('6706000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Laser', + icon: 'Generic-card.png' + }); + }); + it('returns Laser, try 3', () => { + const cardDetails = utils.identifyCard('6771000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Laser', + icon: 'Generic-card.png' + }); + }); + it('returns Laser, try 4', () => { + const cardDetails = utils.identifyCard('6709000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Laser', + icon: 'Generic-card.png' + }); + }); + it('returns Solo, try 1', () => { + const cardDetails = utils.identifyCard('6334000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Solo', + icon: 'Generic-card.png' + }); + }); + it('returns Solo, try 2', () => { + const cardDetails = utils.identifyCard('6767000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Solo', + icon: 'Generic-card.png' + }); + }); + it('returns Maestro, try 6', () => { + const cardDetails = utils.identifyCard('6333000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Maestro, try 7', () => { + const cardDetails = utils.identifyCard('6759000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Verve, try 3', () => { + const cardDetails = utils.identifyCard('6500020000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Verve', + icon: 'Generic-card.png' + }); + }); + it('returns Verve, try 4', () => { + const cardDetails = utils.identifyCard('6500270000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Verve', + icon: 'Generic-card.png' + }); + }); + it('returns Discover Card. try 5', () => { + const cardDetails = utils.identifyCard('6221260000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Discover Card', + icon: 'Discover-card.png' + }); + }); + it('returns Discover Card. try 6', () => { + const cardDetails = utils.identifyCard('6229250000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Discover Card', + icon: 'Discover-card.png' + }); + }); + it('returns Maestro, try 8', () => { + const cardDetails = utils.identifyCard('6666660000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '6*** **** **** 0000', + type: 'Maestro', + icon: 'MAESTRO.png' + }); + }); + it('returns Petroleum / Other Card', () => { + const cardDetails = utils.identifyCard('7000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '7*** **** **** 0000', + type: 'Petroleum / Other Card', + icon: 'Generic-card.png' + }); + }); + it('returns Health / Telco / Other Card', () => { + const cardDetails = utils.identifyCard('8000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '8*** **** **** 0000', + type: 'Health / Telco / Other Card', + icon: 'Generic-card.png' + }); + }); + it('returns National / Other Card', () => { + const cardDetails = utils.identifyCard('9000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: '9*** **** **** 0000', + type: 'National / Other Card', + icon: 'Generic-card.png' + }); + }); + it('returns error invalid card', () => { + const cardDetails = utils.identifyCard('a000000000000000'); + return expect(cardDetails).to.deep.equal({ + hiddenString: 'a*** **** **** 0000', + type: 'Invalid Card', + icon: 'Generic-card.png' + }); + }); + }); +}); diff --git a/node_server/ComServe/specs/valid.spec.js b/node_server/ComServe/specs/valid.spec.js new file mode 100644 index 0000000..03fcfd2 --- /dev/null +++ b/node_server/ComServe/specs/valid.spec.js @@ -0,0 +1,577 @@ +/* globals describe, beforeEach, afterEach, it */ +/** + * Unit testing file for the validation code + */ +'use strict'; +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const chai = require('chai'); +const valid = require('../valid.js'); + +const expect = chai.expect; + +// +// Array of test cases for testing MerchantInvoice validation +// Note that all we need is the RequestAmount and the MerchantInvoice itself as +// this is NOT validating the whole RedeemPaycode (or other function) details. +// See http://10.0.10.242/T1235#30055 for the rules +// +const merchantInvoiceBasic = [ + { + name: 'no merchant invoice', + valid: true, + data: { + RequestAmount: 123 + } + } +]; + +// +// Tests where Items used for MerchantInvoice are on a NET + VAT basis +// +const merchantInvoiceNet = [ + { + name: 'rounding down line', + valid: true, + data: { + RequestAmount: 2, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 2, // 2p + Item_GrossAmount: null, // using Net + Item_Quantity: 1, + Line_VATAmount: 0, // 2p * 20% = 0.4p = round down to 0 + Line_TotalAmount: 2 // Net + VAT + } + ] + } + }, + { + name: 'rounding up line', + valid: true, + data: { + RequestAmount: 4, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 3, // 3p + Item_GrossAmount: null, // using Net + Item_Quantity: 1, + Line_VATAmount: 1, // 3p * 20% = 0.6p = round up to 1 + Line_TotalAmount: 4 // Net + VAT + } + ] + } + }, + { + name: '2 items that would individually round down, but round up in total', + valid: true, + data: { + RequestAmount: 5, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 2, // 2p + Item_GrossAmount: null, // using Net + Item_Quantity: 2, + Line_VATAmount: 1, // 2p * 2 * 20% = 0.8p = round up to 1 + Line_TotalAmount: 5 // Net * 2 + VAT + } + ] + } + }, + { + name: '2 items that would individually round up, but round down in total', + valid: true, + data: { + RequestAmount: 7, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 3, // 3p + Item_GrossAmount: null, // using Net + Item_Quantity: 2, + Line_VATAmount: 1, // 3p * 2 * 20% = 1.2p = round down to 1 + Line_TotalAmount: 7 // Net * 2 + VAT + } + ] + } + }, + + // + // Cases that should fail + // + { + name: 'line INCORRECTLY rounded up', + valid: false, + data: { + RequestAmount: 3, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 2, // 2p + Item_GrossAmount: null, // using Net + Item_Quantity: 1, + Line_VATAmount: 1, // should be 2p * 20% = 0.4p = round down to 0 + Line_TotalAmount: 3 // Net + VAT + } + ] + } + }, + { + name: 'line INCORRECTLY rounded down', + valid: false, + data: { + RequestAmount: 3, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 3, // 3p + Item_GrossAmount: null, // using Net + Item_Quantity: 1, + Line_VATAmount: 0, // Should be 3p * 20% = 0.6p = round up to 1 + Line_TotalAmount: 3 // Net + VAT + } + ] + } + }, + { + name: '2 items that would individually round down but round up in total, being INCORRECTLY rounded down', + valid: false, + data: { + RequestAmount: 4, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 2, // 2p + Item_GrossAmount: null, // using Net + Item_Quantity: 2, + Line_VATAmount: 0, // should be 2p * 2 * 20% = 0.8p = round up to 1 + Line_TotalAmount: 4 // Net * 2 + VAT + } + ] + } + }, + { + name: '2 items that would individually round up but round down in total, being INCORRECTLY rounded up', + valid: false, + data: { + RequestAmount: 6, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 3, // 3p + Item_GrossAmount: null, // using Net + Item_Quantity: 2, + Line_VATAmount: 0, // should be 3p * 2 * 20% = 1.2p = round down to 1 + Line_TotalAmount: 6 // Net * 2 + VAT + } + ] + } + } +]; + +// +// Tests where Items used for MerchantInvoice are on a GROSS basis +// +const merchantInvoiceGross = [ + { + name: 'rounding down line', + valid: true, + data: { + RequestAmount: 2, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 2, // 2p + Item_Quantity: 1, + Line_VATAmount: 0, // 2p - (2p / 120%) = (2p - 1.666...) = 0.22... = round down to 0 + Line_TotalAmount: 2 // Gross * count + } + ] + } + }, + { + name: 'rounding up line', + valid: true, + data: { + RequestAmount: 3, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 3, // 3p + Item_Quantity: 1, + Line_VATAmount: 1, // 3p - (3p/120%) = (3p - 2.5p) = 0.5p = round up to 1 + Line_TotalAmount: 3 // Gross * count + } + ] + } + }, + { + name: '2 items that would individually round down, but round up in total', + valid: true, + data: { + RequestAmount: 4, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 2, // 2p + Item_Quantity: 2, + Line_VATAmount: 1, // 4p - (4p/120%) = (4p - 3.33) = 0.66..p = round up to 1 + Line_TotalAmount: 4 // Gross * 2 + } + ] + } + }, + { + name: '2 items that would individually round up, but are exact pennies in total', + valid: true, + data: { + RequestAmount: 6, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 3, // 3p + Item_Quantity: 2, + Line_VATAmount: 1, // 6p - (6p/120%) = (6 - 5) = 1p exactly + Line_TotalAmount: 6 // Gross * 2 + } + ] + } + }, + { + name: '2 items that would individually round up, but round down in total', + valid: true, + data: { + RequestAmount: 8, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 4, // 4p + Item_Quantity: 2, + Line_VATAmount: 1, // 8p - (8p/120%) = (8 - 6.66) = 1.333 = round down + Line_TotalAmount: 8 // Gross * 2 + } + ] + } + }, + + // + // Cases that should fail + // + { + name: 'INCORRECTLY rounding up line', + valid: false, + data: { + RequestAmount: 3, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 2, // 2p + Item_Quantity: 1, + Line_VATAmount: 1, // should be 2p - (2p / 120%) = (2p - 1.666...) = 0.22... = round down to 0 + Line_TotalAmount: 2 // Gross * count + } + ] + } + }, + { + name: 'INCORRECTLY rounding down line', + valid: false, + data: { + RequestAmount: 3, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 3, // 3p + Item_Quantity: 1, + Line_VATAmount: 0, // should be 3p - (3p/120%) = (3p - 2.5p) = 0.5p = round up to 1 + Line_TotalAmount: 3 // Gross * count + } + ] + } + }, + { + name: '2 items that would individually round down but round up in total, being INCORRECTLY rounded down', + valid: false, + data: { + RequestAmount: 4, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 2, // 2p + Item_Quantity: 2, + Line_VATAmount: 0, // should be 4p - (4p/120%) = (4p - 3.33) = 0.66..p = round up to 1 + Line_TotalAmount: 4 // Gross * 2 + } + ] + } + }, + { + name: '2 items that would individually round up but are exact pennies in total, being INCORRECTLY rounded up', + valid: false, + data: { + RequestAmount: 6, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 3, // 3p + Item_Quantity: 2, + Line_VATAmount: 2, // should be 6p - (6p/120%) = (6 - 5) = 1p exactly + Line_TotalAmount: 6 // Gross * 2 + } + ] + } + }, + { + name: '2 items that would individually round up but round down in total, being INCORRECTLY rounded up', + valid: false, + data: { + RequestAmount: 8, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 4, // 4p + Item_Quantity: 2, + Line_VATAmount: 2, // should be 8p - (8p/120%) = (8 - 6.66) = 1.333 = round down + Line_TotalAmount: 8 // Gross * 2 + } + ] + } + } +]; + +/** + * Tests of mixed types of line items in a single transaction + */ +const merchantInvoiceMixed = [ + { + name: 'one round up, one down, for each of NET and GROSS + one 0% vat', + valid: true, + data: { + RequestAmount: 21, + MerchantInvoice: [ + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 2, // 2p + Item_Quantity: 1, + Line_VATAmount: 0, // 2p - (2p / 120%) = (2p - 1.666...) = 0.22... = round down to 0 + Line_TotalAmount: 2 // Gross * count + }, + { + Item_VATRate: 2000, // 20% + Item_NetAmount: null, // using gross + Item_GrossAmount: 3, // 3p + Item_Quantity: 1, + Line_VATAmount: 1, // 3p - (3p/120%) = (3p - 2.5p) = 0.5p = round up to 1 + Line_TotalAmount: 3 // Gross * count + }, + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 2, // 2p + Item_GrossAmount: null, // using Net + Item_Quantity: 1, + Line_VATAmount: 0, // 2p * 20% = 0.4p = round down to 0 + Line_TotalAmount: 2 // Net + VAT + }, + { + Item_VATRate: 2000, // 20% + Item_NetAmount: 3, // 3p + Item_GrossAmount: null, // using Net + Item_Quantity: 1, + Line_VATAmount: 1, // 3p * 20% = 0.6p = round up to 1 + Line_TotalAmount: 4 // Net + VAT + }, + { + Item_VATRate: 0, // 0% + Item_NetAmount: null, // using Gross + Item_GrossAmount: 10, // 10p + Item_Quantity: 1, + Line_VATAmount: 0, // + Line_TotalAmount: 10 // Gross * count + } + ] + } + } +]; + +/** + * Tests of mixed types of line items in a single transaction + */ +const merchantInvoiceFracQuantity = [ + { + name: 'Line_TotalAmount rounded to nearest (gross)', + valid: true, + data: { + RequestAmount: 1, + MerchantInvoice: [ + { + Item_VATRate: 0, // 0% + Item_NetAmount: null, // using gross + Item_GrossAmount: 1, // 1p + Item_Quantity: 0.5, + Line_VATAmount: 0, // 0 + Line_TotalAmount: 1 // Gross * count = 0.5 = round up to 1p + }, + { + Item_VATRate: 0, // 0% + Item_NetAmount: null, // using gross + Item_GrossAmount: 1, // 1p + Item_Quantity: 0.49, + Line_VATAmount: 0, // 0 + Line_TotalAmount: 0 // Gross * count = 0.49 = round down to 0p + } + ] + } + }, + { + name: 'Line_TotalAmount rounded to nearest (net)', + valid: true, + data: { + RequestAmount: 1, + MerchantInvoice: [ + { + Item_VATRate: 0, // 0% + Item_NetAmount: 1, // 1p + Item_GrossAmount: null, // using net + Item_Quantity: 0.5, + Line_VATAmount: 0, // 0 + Line_TotalAmount: 1 // Gross * count = 0.5 = round up to 1p + }, + { + Item_VATRate: 0, // 0% + Item_NetAmount: 1, // 1p + Item_GrossAmount: null, // using net + Item_Quantity: 0.49, + Line_VATAmount: 0, // 0 + Line_TotalAmount: 0 // Gross * count = 0.49 = round down to 0p + } + ] + } + }, + { + name: 'Line_VATAmount based on post-rounded numbers, not pre-rounded (net)', + valid: true, + data: { + RequestAmount: 3, + MerchantInvoice: [ + { + Item_VATRate: 3000, // 30% + Item_NetAmount: 3, // 3p + Item_GrossAmount: null, // using net + Item_Quantity: 0.5, + Line_VATAmount: 1, // 2p * 0.3 = 0.6 => 1p rounded up. Not 0p if 1.5p used + Line_TotalAmount: 3 // Net * count = 1.5 = round up to 2p. Plus VAT above. + } + ] + } + }, + { + name: 'Line_VATAmount based on post-rounded numbers, not pre-rounded (gross)', + valid: true, + data: { + RequestAmount: 2, + MerchantInvoice: [ + { + Item_VATRate: 4000, // 40% + Item_NetAmount: null, // using gross + Item_GrossAmount: 3, // 3p + Item_Quantity: 0.5, + Line_VATAmount: 1, // 2p - (2p / 1.4) = 2-1.43 = 0.57 => 0p rounded down. Not 1p if 1.5p used + Line_TotalAmount: 2 // Gross * count = 1.5 = round up to 2p. + } + ] + } + } + +]; + +/** + * Categories of test cases + */ +const groups = [ + { + description: 'general transactions', + cases: merchantInvoiceBasic + }, + { + description: 'transaction with merchant invoice with NET item(s)', + cases: merchantInvoiceNet + }, + { + description: 'transaction with merchant invoice with GROSS item(s)', + cases: merchantInvoiceGross + }, + { + description: 'transaction with merchant invoice with MIXED item(s)', + cases: merchantInvoiceMixed + }, + { + description: 'transaction with fractional quantity of items', + cases: merchantInvoiceFracQuantity + } +]; + +/** + * Applies the test cases array, creating a test case for each one. + * + * @param {Object[]} cases - array of test cases + */ +function applyTestCases(cases) { + const validate = valid.validateRedeemPayCode; + + for (let i = 0; i < cases.length; ++i) { + /** + * Get the testcase, then the test data in the correct format. + */ + const tc = cases[i]; + + /** + * Build a meaningful name for the test. + */ + let name = tc.valid ? 'should accept ' : 'should NOT accept '; + + name += tc.name; + + /** + * Run a test for this case. + */ + it(name, () => { + const expected = expect(validate(tc.data)); + + if (tc.valid) { + return expected.to.equal(null); + } else { + return expected.to.include({code: 174}); + } + }); + } +} + +/** + * Unit test definitions + */ +describe('validation', () => { + describe('validateRedeemPaycode', () => { + // + // Loop through all the groups of tests cases, adding a describe for each one + // + for (let i = 0; i < groups.length; ++i) { + describe(groups[i].description, () => { + applyTestCases(groups[i].cases); + }); + } + }); +}); diff --git a/node_server/ComServe/utils.js b/node_server/ComServe/utils.js new file mode 100644 index 0000000..07076cc --- /dev/null +++ b/node_server/ComServe/utils.js @@ -0,0 +1,1090 @@ +/* eslint-disable complexity */ +/* eslint-disable id-length */ +/** + * @fileOverview Node.js Bridge Server Constants and General Utils (IBM) + * @preserve Copyright 2014-2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ + +/** + * Includes + */ +const crypto = require('crypto'); +const mongodb = require('mongodb'); +const moment = require('moment'); +const _ = require('lodash'); + +const config = require(global.configFile); +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const log = require(global.pathPrefix + 'log.js'); +const crc = require('crc'); + +/** + * Sample strings for input checking. + */ +exports.CarriageReturn = '\n'; // Use for on screen. +exports.generalText = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\'[]()@?!-/.,_&*:;+='; +exports.fullAlphaNumeric = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +exports.alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; +exports.paycodeString = '0123456789ABCDEFGHJKLMNPRSTUVWXY'; +exports.lowerCaseHex = '0123456789abcdef'; +exports.numeric = '0123456789'; +exports.version = '0123456789.'; +exports.space = ' '; +exports.fwslash = '/'; +exports.dash = '-'; +exports.floatChars = '.eExX-'; +exports.hexadecimal = '0123456789ABCDEF'; + +/** + * System variables. Changing these variables will have a knock on effect so this should be done with care. + */ +exports.tokenLength = 42; +exports.userIdLength = 24; +exports.shortTokenLength = 12; +exports.SMStokenLength = 6; +exports.systemState = {firstTime: 1, + shutdownTick: -1, + dbWaiting: 0}; +exports.webTimeout = 20000; // Milliseconds. +exports.maxPacketSize = 100000; // Maximum packet upload size before break off. +exports.maxQuantity = 32000; // Maximum number of items in one invoice line. Arbitrary limit. +exports.encryptionIVLength = 16; // 128 bits, 16 bytes. AES256 still uses 128 bit blocks. +exports.twoFactorRequestExpiry = 120; // Seconds lifetime for 2FA Requests +exports.twoFactorTokenLength = 64; // Bits in the two +exports.MinDisplayNameLength = 2; // Minimum number of characters that a DisplayName must have. +exports.MaxIntegrationTokens = 10; // Maximum number of integration tokens a client can have + +/** + * Default values for system configuration. These can be changed with little knock on effect. + */ +exports.paymentMin = 0; // Beta is 0p. Enforced in RedeemPayCode. +exports.paymentMax = 25000; // Beta is GBP250. Enforced in RedeemPayCode. +exports.tipMin = 0; // Beta is 0p. Enforced in ConfirmTransaction. +exports.tipMax = 5000; // Beta is GBP50. Enforced in ConfirmTransaction. +exports.transactionMin = 50; // Beta is 50p. Enforced in ConfirmTransaction. +exports.pollingInterval = 1; // Seconds between consecutive status update requests. +exports.payCodeTimeout = 3; // Interval in minutes since last request before the PayCode is timed out. +exports.sessionTimeout = 5; // Interval in minutes since last activity before session times out. +exports.imageFileLimit = 50000; // Image file limit in Base64 encoded bytes. +exports.passwordLockout = 20; // Number password attempts before user is locked out. +exports.PINLockout = 3; // Number PIN attempts before user is locked out. +exports.smsTokenDuration = 1; // Number of hours that the SMS registration token is valid for. +exports.recoveryInitialDelay = 1; // Initial imeout before allowing next retry. Increases exponentially with failures +exports.recoveryRetries = 3; // The number of retries you are allowed for any step of recovery +exports.recoveryQuestionsCount = 3; // Number of questions to use for knowledge-based authentication. +exports.transactionMinText = 'GBP ' + (exports.transactionMin / 100.00).toFixed(2); +exports.transactionMaxText = 'GBP ' + ((exports.paymentMax + exports.tipMax) / 100.00).toFixed(2); + +/** + * Some type constants - add more to the arrays as needed + */ +exports.ImageTypeChoice = ['Selfie', 'CompanyLogo0']; +exports.FileTypeChoice = ['PNG', 'JPG', 'JPEG']; +exports.CountryChoice = ['United Kingdom']; +exports.OperatorNameChoice = ['Comcarde']; +exports.ImageWidthMax = 100; +exports.ImageHeightMax = 100; + +/** + * Device status bit masks. + */ +exports.DeviceRegister2Mask = 0x01; // Device verified - SMS confirmed. +exports.DeviceRegister3Mask = 0x02; // Device authorised - PIN has been set. +exports.DeviceFullyRegistered = 0x03; // Register 2 and 3 complete. +exports.DeviceSuspendedMask = 0x04; // When set, device has been put on hold by the user (lost phone). +exports.DeviceBarredMask = 0x08; // When set, the device has been barred by Comcarde (possible fraud). + +/** + * Client status bit masks. + */ +exports.ClientEmailVerifiedMask = 0x01; // E-mail link has been clicked. +exports.ClientDetailsMask = 0x02; // Client name has been added. +exports.ClientAddressMask = 0x04; // The client has one or more addresses in the database. Not reset when addresses cleared out. +exports.ClientBarredMask = 0x08; // When 1, the account has been put on hold by Comcarde (possible fraud). +exports.ClientCantReport = 0x10; // When 1, the account cannot report images. Typically set if the facility is abused. +exports.ClientRefer = 0x20; // More details are required to veriy identity +exports.ClientPeps = 0x40; // Client matches one or more PEPs. +exports.ClientSanctions = 0x80; // Client matches with people with Sanctions. + +/* jshint -W016 */ +exports.ClientKycIncompleteMask = + exports.ClientRefer | + exports.ClientSanctions; +/* jshint +W016 */ + +/** + * Account status bit masks. + */ +exports.AccountLocked = 0x01; // The account cannot be removed from the device. +exports.AccountDeleted = 0x02; // The account has been deleted. +exports.AccountApiCreated = 0x04; // The account was created automatically by the Integration API + +/** + * Transaction status codes. + */ +exports.TransactionComplete = 3; // Completed successfully. +exports.TransactionRefunded = 4; // Transaction has been refunded completely. + +/** + * Transaction history status codes. + */ +exports.HistoryCustOutgoing = 0; // The party (customer) has sent this amount to other. +exports.HistoryMerchIncoming = 1; // The party (merchant) is in receipt of this amount from other. +exports.HistoryMerchRefund = 2; // The party (merchant) has refunded this amount to other. +exports.HistoryCustRefund = 3; // The party (customer) is in receipt of refund from other. + +/** + * Merchant status bit masks + */ +exports.MerchantStatusInactive = 0x00; +exports.MerchantStatusActive = 0x01; +exports.MerchantStatusExpired = 0x02; +exports.MerchantStatusPending = 0x03; +exports.MerchantStatusBlocked = 0x04; +exports.MerchantStatusBarred = 0x05; + +/** + * Item status bit masks + */ +exports.ItemStatusActive = 0x01; // Item is active on the system +exports.ItemStatusDeleted = 0x02; // Item has been soft deleted + +/** + * Transaction Status definitions + */ +exports.TransactionStatus = { + // Active or complete. + LIVE: 0, + CLAIMED: 1, + CONFIRMED: 2, + COMPLETE: 3, + REFUNDED: 4, + + // Cancelled. + CANCELLED_BEFORE_USE: 10, + CANCELLED_AFTER_CLAIM: 11, + DECLINED: 12, + NO_CUSTOMER: 13, + NO_MERCHANT: 14, + CANNOT_RECEIVE: 15, + ABORTED: 16, + EXPIRED_PAYCODE: 17, + + // Invoicing. + PENDING_INVOICE: 20, + REJECTED_INVOICE: 21, + CANCELLED_INVOICE: 22, + + // + // Direct payment + // + PENDING_DIRECT_PAYMENT: 30 +}; + +/** + * Payment Instrument Type values + */ +exports.PaymentInstrumentType = { + UNSPECIFIED: 'Unspecified', + CREDIT_DEBIT_PAYMENT_CARD: 'Credit/Debit Payment Card', + WORLDPAY_ONLINE_PAYMENTS_ACCOUNT: 'Worldpay Online Payments Account' +}; + +/** + * Card type information + */ +exports.AccountClass = { + UNKNOWN: 'unknown', + CREDIT: 'credit', + DEBIT: 'debit' +}; + +exports.CardTypes = { + UNKNOWN: 'Unknown', + VISA_CREDIT: 'Visa Credit', + VISA_DEBIT: 'Visa Debit', + VISA_CORPORATE_CREDIT: 'Visa Corporate Credit', + VISA_CORPORATE_DEBIT: 'Visa Corporate Debit', + MASTERCARD_CREDIT: 'Mastercard Credit', + MASTERCARD_DEBIT: 'Mastercard Debit', + MASTERCARD_CORPORATE_CREDIT: 'Mastercard Corporate Credit', + MASTERCARD_CORPORATE_DEBIT: 'Mastercard Corporate Debit', + MAESTRO: 'Maestro', + AMEX: 'American Express', + CARTEBLEUE: 'Cartebleue', + JCB: 'JCB', + DINERS: 'Diners' +}; + +/** + * Checks if *all* the bits in the bitmask are set in the test value. + * + * @type {Function} bitsAllSet + * @param {!int} value - the value to test + * @param {!int} bitmask - the bitmask to test against + * @returns {boolean} - True if all the bits in the bitmask are set. + */ +exports.bitsAllSet = function(value, bitmask) { + // jshint -W016 + return ((value & bitmask) === bitmask); + // jshint +W016 +}; + +/** + * Checks if *any* of the bits in the bitmask are set in the test value + * + * @type {Function} bitsAnySet + * @param {int} value - the value to test + * @param {int} bitmask - the bitmask to test against + * @returns {boolean} - true if any of the bits in the bitmask are set + */ +exports.bitsAnySet = function(value, bitmask) { + // jshint -W016 + return ((value & bitmask) !== 0); + // jshint +W016 +}; + +/** + * Generates a random code. This function is strong enough for cryptographic use unless system entropy is below certain level. + * The function may block for a couple of miliseconds until this is the case. + * + * @type {Function} randomCode + * @param {!string} list - The string to use e.g. 'utils.numeric' for decimal. All shown at the top of this file. + * @param {?int} length - The length of the resulting output string. + * @returns {string} temp.join - A string of random characters from the list. Note that a blank string will be returned on error. + */ +exports.randomCode = function(list, length) { + /** + * Local variables. + */ + let bytes; + const temp = []; + + try { + /** + * Create a string of random bytes. + */ + bytes = crypto.randomBytes(length); + } catch (error) { + /** + * Error has been thrown so log and return a blank string. + */ + log.system( + 'ERROR', + 'Error in randomisation module. ' + error.name + ' (' + error.message + ')', + 'utils.randomCode', + '', + 'System', + '127.0.0.1'); + return ''; + } + + /** + * Repeat for the defined number of bytes. + */ + for (let x = 0; x < length; x++) { + temp.push(list[bytes[x] % list.length]); + } + + /** + * Return the string. + */ + return temp.join(''); +}; + +/** + * Cleans up a text string to ensure it only contains valid characters. Invalid characters are simply dropped. + * If maxChars is provided, the string will be clipped at this number of characters. + * + * @type {Function} cleanUpString + * @param {!string} inputString - The string to be cleaned up. + * @param {!string} whitelist - The characters that are allowed to be in the new string. + * @param {!int} maxChars - The length of the resulting output string. 0 indicates no limit. + * @returns {string} A cleaned up string of the allowed characters up to a maximum of maxChars. + */ +exports.cleanUpString = function(inputString, whitelist, maxChars) { + /** + * Use RegEx to replace unwanted characters from the whitelist. + */ + const regexString = '[^' + whitelist + ']*'; // Wrap the whitelist with not operator, and repeat 0-many times. + const regex = new RegExp(regexString, 'g'); // Convert to a regular expression (with global match flag) + let newString = inputString.replace(regex, ''); // Replace everything not in the whitelist with empty string + newString = newString.slice(0, maxChars); // Cut down to the max length + return newString; +}; + +/** + * Generates a random code that: + * - Starts with the current date time to make collisions less likely + * - Uses a fairly sanitised set of characters that are acceptable payment gateways. + * + * @returns {string} - random string + */ +exports.timeBasedRandomCode = function() { + return moment().format('YYYYMMDDTHHmmssSSS') + + exports.randomCode(exports.fullAlphaNumeric, 14); +}; + +/* + * Returns an object that contains both an error code and short message. Designed to simplify error returns in the code. + * + * @type {function} createError + * @param {!int} code - Integer code representing the error. + * @param {!string} message - Message describing the integer code. + * @param {?string} httpCode - httpStatus code representing the error. + * @return {object} errorReturn - Returns an error with both code and message. + */ +exports.createError = function(code, message, httpCode) { + const errorReturn = {}; + errorReturn.code = code; + errorReturn.message = message; + errorReturn.httpCode = httpCode; + return errorReturn; +}; + +/** + * AES256 encryption of text using password. + * + * @type {Function} encryptAES256 + * @param {!string} text - Text string to encrypt. + * @param {!string} password - Password to use to encrypt the text string. + * @param {!string} encoding - Encoding methodology. Earlier versions used 'aes-256-ctr' but this can be weak due to parallelisation. + * New versions should use 'aes-256-cbc' as it is sequential. + * @returns {string} crypted - Hex encoded string containing encrypted data. + */ +exports.encryptAES256 = function(text, password, encoding) { + const cipher = crypto.createCipher(encoding, password); + let crypted = cipher.update(text, 'utf8', 'hex'); + crypted += cipher.final('hex'); + return crypted; +}; + +/** + * AES256 decryption of hex using password. + * + * @type {Function} decryptAES256 + * @param {!string} text - Hex encoded string containing encrypted data. + * @param {!string} password - Password to use to decrypt the hex string. + * @param {!string} encoding - Encoding methodology. Earlier versions used 'aes-256-ctr' but this can be weak due to parallelisation. + * New versions should use 'aes-256-cbc' as it is sequential. + * @returns {string | null} Decoded text string or null in case of an error. + */ +exports.decryptAES256 = function(text, password, encoding) { + try { + const decipher = crypto.createDecipher(encoding, password); + let dec = decipher.update(text, 'hex', 'utf8'); + dec += decipher.final('utf8'); + return dec; + } catch (error) { + /** + * Error decrypting information. Incorrect passwords will throw an error with CBC. + */ + return null; + } +}; + +/** + * Processes a card number and fills in various details needed for payment. + * + * https://en.wikipedia.org/wiki/Payment_card_number + * + * @type {Function} identifyCard + * @param {!string} PAN - Card PAN number. + * @returns {Object} cardInfo - Further information on card from standard tables. + * + **/ +exports.identifyCard = function(PAN) { + const cardInfo = {}; + let number = 0; + + /** + * Hide digits. + */ + cardInfo.hiddenString = PAN.charAt(0) + '*** **** **** ' + PAN.substr(12, 4); + + /** + * Examine the first character and process appropriately. + */ + switch (PAN.charAt(0)) { + // eslint-disable-next-line lines-around-comment + /** + * 0 ISO/TC 68 and other future industry assignments. + */ + case '0': + cardInfo.type = 'ISO / TC68 Card'; + cardInfo.icon = 'Generic-card.png'; + break; + + /** + * 1 Airlines. + */ + case '1': + cardInfo.type = 'Airline/UATP'; + cardInfo.icon = 'Generic-card.png'; + break; + + /** + * 2 Airlines and other future industry assignments. + */ + case '2': + number = parseInt(PAN.substr(0, 4), 10); + if ((number === 2014) || (number === 2149)) { + cardInfo.type = 'Diners Club enRoute'; + cardInfo.icon = 'Diners-Generic.png'; + } else if ((2200 <= number) && (number <= 2204)) { + cardInfo.type = 'MIR'; + cardInfo.icon = 'MIR.png'; + } else if ((2221 <= number) && (number <= 2720)) { + cardInfo.type = 'Mastercard'; + cardInfo.icon = 'MASTERCARD_CREDIT.png'; + } else { + cardInfo.type = 'Airline/UATP/Other Card'; + cardInfo.icon = 'Generic-card.png'; + } + break; + + /** + * 3 Travel and entertainment and banking/financial. + */ + case '3': + number = parseInt(PAN.substr(0, 2), 10); + if ((number === 34) || (number === 37)) { + cardInfo.type = 'American Express'; + cardInfo.icon = 'AMEX.png'; + } else if ((number === 36) || (number === 38) || (number === 39)) { + cardInfo.type = 'Diners Club International'; + cardInfo.icon = 'DINERS.png'; + } else { + number = parseInt(PAN.substr(0, 3), 10); + if ((300 <= number) && (number <= 305)) { + cardInfo.type = 'Diners Club'; // Could be International or Carte Blanche. + cardInfo.icon = 'DINERS.png'; + } else if (number === 309) { + cardInfo.type = 'Diners Club International'; + cardInfo.icon = 'DINERS.png'; + } else { + number = parseInt(PAN.substr(0, 4), 10); + if ((3528 <= number) && (number <= 3589)) { + cardInfo.type = 'JCB'; + cardInfo.icon = 'JCB.png'; + } else { + cardInfo.type = 'Other Card'; + cardInfo.icon = 'Generic-card.png'; + } + } + } + break; + + /** + * 4 Banking and financial + */ + case '4': + number = parseInt(PAN.substr(0, 4), 10); + if ((number === 4175) || (number === 4571)) { + cardInfo.type = 'Dankort'; + cardInfo.icon = 'Dankort.png'; + } else if ((number === 4903) || (number === 4905) || (number === 4911) || (number === 4936)) { + cardInfo.type = 'Maestro'; + cardInfo.icon = 'MAESTRO.png'; + } else { // Almost certainly Visa. + cardInfo.type = 'Visa'; + cardInfo.icon = 'VISA_CREDIT.png'; + } + break; + + /** + * 5 Banking and financial. + */ + case '5': + number = parseInt(PAN.substr(0, 6), 10); + if (number === 564182) { + cardInfo.type = 'Maestro'; + cardInfo.icon = 'MAESTRO.png'; + } else if ((560221 <= number) && (number <= 560225)) { + cardInfo.type = 'Bankcard'; + cardInfo.icon = 'Generic-card.png'; + } else if ((506099 <= number) && (number <= 506198)) { + cardInfo.type = 'Verve'; + cardInfo.icon = 'Generic-card.png'; + } else { + number = parseInt(PAN.substr(0, 4), 10); + if (number === 5019) { + cardInfo.type = 'Dankort'; + cardInfo.icon = 'Dankort.png'; + } else if (number === 5610) { + cardInfo.type = 'Bankcard'; + cardInfo.icon = 'Generic-card.png'; + } else if (number === 5392) { + cardInfo.type = 'CardGuard'; + cardInfo.icon = 'Generic-card.png'; + } else { + number = parseInt(PAN.substr(0, 2), 10); + if ((51 <= number) && (number <= 55)) { + cardInfo.type = 'MasterCard'; + cardInfo.icon = 'MASTERCARD_CREDIT.png'; + } else if ((number === 50) || (number === 56) || (number === 57) || (number === 58)) { + cardInfo.type = 'Maestro'; + cardInfo.icon = 'MAESTRO.png'; + } else { + cardInfo.type = 'Other Card'; + cardInfo.icon = 'Generic-card.png'; + } + } + } + break; + + /** + * 6 Merchandising and banking/financial + */ + case '6': + number = parseInt(PAN.substr(0, 6), 10); + if ((650002 <= number) && (number <= 650027)) { + cardInfo.type = 'Verve'; + cardInfo.icon = 'Generic-card.png'; + } else if ((622126 <= number) && (number <= 622925)) { + cardInfo.type = 'Discover Card'; + cardInfo.icon = 'Discover-card.png'; + } else { + number = parseInt(PAN.substr(0, 4), 10); + if (number === 6011) { + cardInfo.type = 'Discover Card'; + cardInfo.icon = 'Discover-card.png'; + } else if ((number === 6304) || (number === 6706) || (number === 6771) || (number === 6709)) { + cardInfo.type = 'Laser'; + cardInfo.icon = 'Generic-card.png'; + } else if ((number === 6334) || (number === 6767)) { + cardInfo.type = 'Solo'; + cardInfo.icon = 'Generic-card.png'; + } else if ((number === 6333) || (number === 6759)) { + cardInfo.type = 'Maestro'; + cardInfo.icon = 'MAESTRO.png'; + } else { + number = parseInt(PAN.substr(0, 3), 10); + if ((number === 637) || (number === 638) || (number === 639)) { + cardInfo.type = 'InstaPayment'; + cardInfo.icon = 'Generic-card.png'; + } else if ((644 <= number) && (number <= 649)) { + cardInfo.type = 'Discover Card'; + cardInfo.icon = 'Discover-card.png'; + } else if (number === 636) { + cardInfo.type = 'InterPayment Card'; + cardInfo.icon = 'Generic-card.png'; + } else { + number = parseInt(PAN.substr(0, 2), 10); + if (number === 62) { + cardInfo.type = 'China UnionPay'; + cardInfo.icon = 'Generic-card.png'; + } else if (number === 65) { + cardInfo.type = 'Discover Card'; + cardInfo.icon = 'Discover-card.png'; + } else { + cardInfo.type = 'Maestro'; // Very likely Maestro. + cardInfo.icon = 'MAESTRO.png'; + } + } + } + } + break; + + /** + * 7 Petroleum and other future industry assignments + */ + case '7': + cardInfo.type = 'Petroleum / Other Card'; + cardInfo.icon = 'Generic-card.png'; + break; + + /** + * 8 Healthcare, telecommunications and other future industry assignments + */ + case '8': + cardInfo.type = 'Health / Telco / Other Card'; + cardInfo.icon = 'Generic-card.png'; + break; + + /** + * 9 National assignment + */ + case '9': + cardInfo.type = 'National / Other Card'; + cardInfo.icon = 'Generic-card.png'; + break; + + /** + * There is a problem with the card number. + */ + default: + cardInfo.type = 'Invalid Card'; + cardInfo.icon = 'Generic-card.png'; + break; + } + + /** + * Return the detailed card info. + */ + return cardInfo; +}; + +/* + * Returns an encrypted version of payment information based on the V1 spec. This function is based on AES256. + * Note that the function automatically pulls config.AESKey. Ideally this should come from a key store. + * This function returns a string on success or an object on an error. + * + * @type {function} encryptDataV1 + * @param {!string} toEncrypt - String to encrypt. + * @return {?string} Data to be stored in the database: '1::data' will be returned on success. + * @return {?object} Error condition {code: 1, message: 'Nothing to encrypt.'} + */ +exports.encryptDataV1 = function(toEncrypt) { + /** + * Check data before encryption. + */ + if (!toEncrypt) { + return exports.createError(1, 'Nothing to encrypt.'); + } + if (!(_.isString(toEncrypt))) { + return exports.createError(2, 'Data to encrypt must be a string.'); + } + + /** + * Build the encryption string. + */ + const IV = exports.randomCode(exports.generalText, exports.encryptionIVLength); + let CRC32 = crc.crc32(IV + toEncrypt).toString(16); + + /** + * Pad the CRC32 to 8 characters. + */ + if (CRC32.length !== 8) { + if ((8 < CRC32.length) || (1 > CRC32.length)) { + return exports.createError(3, ('Unexpected CRC32 length: ' + CRC32.length + '.')); + } + let counter = 8 - CRC32.length; + while (counter) { + CRC32 = '0' + CRC32; + counter -= 1; + } + } + + /** + * Encrypt and return. Note the use of CBC! Do not use CTR. + */ + const encryptedData = exports.encryptAES256((IV + toEncrypt + CRC32), config.AESKey, 'aes-256-cbc'); + return ('1::' + encryptedData); +}; + +/* + * Takes an encrypted version of payment information based on the V1 spec and decrypts it. The function also verifies that + * the information was decrypted correctly using the CRC 32. This function is based on AES256. + * Note that the function automatically pulls config.AESKey. Ideally this should come from a key store. + * This function returns the decrypted string on success or an object on error. + * + * @type {function} decryptDataV1 + * @param {!string} toDecrypt - String to decrypt in format '1::data'. + * @return {?string} Decrypted data will be returned on success. + * @return {?object} Error condition {code: 1, message: 'Nothing to decrypt.'} + */ +exports.decryptDataV1 = function(toDecrypt) { + /** + * Check data before decryption. + */ + if (!toDecrypt) { + return exports.createError(1, 'Nothing to decrypt.'); + } + if (!(_.isString(toDecrypt))) { + return exports.createError(2, 'Data to be decrypted must be a string.'); + } + const splitData = toDecrypt.split('::'); + if (splitData.length !== 2) { + return exports.createError(3, 'Encrypted data did not contain the 2 expected elements.'); + } + if (splitData[0] !== '1') { + return exports.createError(4, 'Unexpected encryption version.'); + } + + /** + * Deconstruct the encrypted string. + */ + let decryptedData = exports.decryptAES256(splitData[1], config.AESKey, 'aes-256-cbc'); + if (!decryptedData) { + return exports.createError(5, 'Decryption error.'); + } + const receivedCRC32 = decryptedData.substr(-8); + decryptedData = decryptedData.substr(0, (decryptedData.length - 8)); + let CRC32 = crc.crc32(decryptedData).toString(16); + + /** + * Pad the CRC32 to 8 characters. + */ + if (CRC32.length !== 8) { + if ((8 < CRC32.length) || (1 > CRC32.length)) { + return exports.createError(6, ('Unexpected CRC32 length: ' + CRC32.length + '.')); + } + let counter = 8 - CRC32.length; + while (counter) { + CRC32 = '0' + CRC32; + counter -= 1; + } + } + + /** + * Verify the CRC. + */ + if (receivedCRC32 !== CRC32) { + return exports.createError(7, 'Invalid key.'); + } + + /** + * Return the decrypted data. + */ + decryptedData = decryptedData.substr(exports.encryptionIVLength, decryptedData.length); + return decryptedData; +}; + +/* + * Returns an encrypted version of payment information based on the V3 spec. This function is based on AES256 but requires + * the ClientKey from the user. This is prepended with config.AESKey to lock it to the system. In addition, each piece of encrypted data + * will only be returned to the client that owns the data - the same clientId must be used or decrypt will return an error. + * This function returns a string on success or an object on an error. Note that this function uses CRC32 for integrity checking; + * V4 will use HMAC but it does consume more CPU cycles. + * + * @type {function} encryptDataV3 + * @param {!string} toEncrypt - String to encrypt. + * @param {!string} clientKey - Key from the Client which is used to encrypt the data. The key should be sent hashed (SHA256). + * @param {!string} clientId - MongoDB ID of the client to which the data belongs. + * @return {?string} Data to be stored in the database: '3::data' will be returned on success. + * @return {?object} Error condition {code: 1, message: 'Nothing to encrypt.'} + */ +exports.encryptDataV3 = function(toEncrypt, clientKey, clientId) { + /** + * Check data before encryption. + */ + if (!toEncrypt) { + return exports.createError(1, 'Nothing to encrypt.'); + } + if (!clientKey) { + return exports.createError(2, 'No client key.'); + } + if (!clientId) { + return exports.createError(3, 'No client ID.'); + } + if (!(_.isString(toEncrypt))) { + return exports.createError(4, 'Data to encrypt must be a string.'); + } + if (!(_.isString(clientKey))) { + return exports.createError(5, 'Client key must be a string.'); + } + if (!clientKey.match(/^[a-fA-F0-9]+$/g)) { + return exports.createError(5, 'Client Key must be in Hex'); + } + if (!(_.isString(clientId))) { + return exports.createError(6, 'Client ID must be a string.'); + } + if (clientId.length !== 24) { + return exports.createError(3, 'Client Id length must be 24'); + } + + /** + * Build the encryption string. + */ + const IV = exports.randomCode(exports.generalText, exports.encryptionIVLength); + let CRC32 = crc.crc32(IV + clientId + toEncrypt).toString(16); + + /** + * Pad the CRC32 to 8 characters. + */ + if (CRC32.length !== 8) { + if ((8 < CRC32.length) || (1 > CRC32.length)) { + return exports.createError(7, ('Unexpected CRC32 length: ' + CRC32.length + '.')); + } + let counter = 8 - CRC32.length; + while (counter) { + CRC32 = '0' + CRC32; + counter -= 1; + } + } + + /** + * Create a new buffer that concatenates the client's key and the current system key. + */ + const newPassword = Buffer.from((clientKey + config.hashedAESKey), 'hex'); + const encryptedData = exports.encryptAES256((IV + clientId + toEncrypt + CRC32), newPassword, 'aes-256-cbc'); + + return ('3::' + encryptedData); +}; + +/* + * Returns a decrypted version of payment information based on the V3 spec. This function is based on AES256 but requires + * the ClientKey from the user. This is appended with config.AESKey as it is locked to the system. In addition, each piece of encrypted + * data will only be returned to the clientId that owns the data. This is stored in the encrypted information. + * This function returns a string on success or an object on an error. + * + * @type {function} decryptDataV3 + * @param {!string} toDecrypt - String to decrypt in format '3::data'. + * @param {!string} clientKey - Key from the Client which is used to encrypt the data. This should be a SHA256. + * @param {!string} clientId - MongoDB ID of the client to which the data belongs. + * @return {?string} Decrypted data will be returned on success. + * @return {?object} Error condition {code: 1, message: 'Nothing to decrypt.'} + */ +exports.decryptDataV3 = function(toDecrypt, clientKey, clientId) { + /** + * Check data before decryption. + */ + if (!toDecrypt) { + return exports.createError(1, 'Nothing to decrypt.'); + } + if (!clientKey) { + return exports.createError(2, 'No client key.'); + } + if (!clientId) { + return exports.createError(3, 'No client ID.'); + } + if (!(_.isString(toDecrypt))) { + return exports.createError(4, 'Data to be decrypted must be a string.'); + } + if (!(_.isString(clientKey))) { + return exports.createError(5, 'Client key must be a string.'); + } + if (!(_.isString(clientId))) { + return exports.createError(6, 'Client ID must be a string.'); + } + const splitData = toDecrypt.split('::'); + if (splitData.length !== 2) { + return exports.createError(7, 'Encrypted data did not contain the 2 expected elements.'); + } + if (splitData[0] !== '3') { + return exports.createError(8, 'Unexpected encryption version.'); + } + + /** + * Deconstruct the encrypted string. + */ + const newPassword = Buffer.from((clientKey + config.hashedAESKey), 'hex'); + let decryptedData = exports.decryptAES256(splitData[1], newPassword, 'aes-256-cbc'); + if (!decryptedData) { + return exports.createError(9, 'Decryption error.'); + } + const receivedCRC32 = decryptedData.substr(-8); + decryptedData = decryptedData.substr(0, (decryptedData.length - 8)); + let CRC32 = crc.crc32(decryptedData).toString(16); + + /** + * Pad the CRC32 to 8 characters. + */ + if (CRC32.length !== 8) { + if ((8 < CRC32.length) || (1 > CRC32.length)) { + return exports.createError(10, ('Unexpected CRC32 length: ' + CRC32.length + '.')); + } + let counter = 8 - CRC32.length; + while (counter) { + CRC32 = '0' + CRC32; + counter -= 1; + } + } + + /** + * Verify the CRC. + */ + if (receivedCRC32 !== CRC32) { + return exports.createError(11, 'Invalid key.'); + } + + /** + * Check this client is allowed to access the information. + */ + const decryptedClientId = decryptedData.substr(exports.encryptionIVLength, 24); + if (clientId !== decryptedClientId) { + return exports.createError(12, 'Information does not belong to client.'); + } + + /** + * Return the decrypted data. + */ + decryptedData = decryptedData.substr((exports.encryptionIVLength + 24), decryptedData.length); + return decryptedData; +}; + +/** + * Accounts error list that usually appears with ListAccounts. Also used by integrity checking code. + * Please update the following page if there are any changes. + * http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/listaccounts/#integrity + */ +exports.ACCOUNT_ERR = { + CARD_EXP: 'Card has expired.', // Ask for new card details. + CARD_EXP_DUE: 'Card expires at the end of this month.', // Ask for new card details. + CARD_PAN_DEC: 'Cannot decrypt card number.', // Re-enter card details or correct ClientKey. + CARD_VALID_DEC: 'Cannot decrypt card valid from date.', // Re-enter card details or correct ClientKey. + CARD_EXP_DEC: 'Cannot decrypt card expiry date.', // Re-enter card details or correct ClientKey. + CARD_ISS_DEC: 'Cannot decrypt issue number.', // Re-enter card details or correct ClientKey. + NO_BILLING_ADD: 'No valid BillingAddress.', // Ask for a new billing address. + ERR_BALANCE: 'Incorrect account balance.', // Call Comcarde. + ERR_TOTAL: 'Incorrect account total.', // Call Comcarde. + ERR_KEYS: 'Incorrect number of keys.' // Call Comcarde. +}; + +/* + * Takes an expiry date and checks against the supplied timestamp to see if the card has expired. The function + * will also return a warning if the card is in its last month of validity. + * + * @type {function} checkCardExpiry + * @param {!string} cardExpiry - Card expiry string of the format 'MM-YY'. + * @param {!string} timestamp - Javascript Date() object containing the timestamp to be compared. + * @return {string | null} If null, the card is valid and has at least a month left to run. Given problems, + * the function will return 'Card has expired.' or 'Card expires at the end of this month.'. + */ +exports.checkCardExpiry = function(cardExpiry, timestamp) { + const currentMonth = timestamp.getMonth(); + const currentYear = timestamp.getFullYear() - 2000; + const expiryMonth = parseInt(cardExpiry.substr(0, 2), 10); + const expiryYear = parseInt(cardExpiry.substr(3, 2), 10); + + /** + * Check the various cases. + */ + if (currentYear > expiryYear) { + return exports.ACCOUNT_ERR.CARD_EXP; + } else if (currentYear === expiryYear) { + if (currentMonth > expiryMonth) { + return exports.ACCOUNT_ERR.CARD_EXP; + } else if (currentMonth === expiryMonth) { + return exports.ACCOUNT_ERR.CARD_EXP_DUE; + } + } + + /** + * If we got here it's still valid. + */ + return null; +}; + +/* + * Takes encrypted information and a ClientKey (if appropriate). Returns decrypted information + * This function returns a string on success or an object on an error. + * + * @type {function} checkAccountInformation + * @param {!string} toDecrypt - String to decrypt. Format will be interpreted automatically. + * @param {!string} clientKey - Key from the Client which is used to encrypt the data. This should be a SHA256. + * @param {!string} clientId - MongoDB ID of the client to which the data belongs. + * @param {!string} accountID - MongoDB ID of the account to which the data belongs. Account will be upgraded if non-null. + * @param {!string} accountField - Name of the field that is being decrypted. Used for upgrading information. + * @return {null | object} If null is returned then the ClientKey / ClientID is correct for this Field. + * If an object is returned then there is an error condition eg {code: 1, message: 'Nothing to decrypt.'} + */ +exports.checkAccountInformation = function(toDecrypt, clientKey, clientId, accountID, accountField) { + /** + * Split up the information to decrypt and check the version. + */ + const splitData = toDecrypt.split('::'); + if (splitData.length !== 2) { + return exports.createError( + 1, + ('Account ' + accountID + ', ' + accountField + ': No encryption code detected.'), + 'utils.checkAccountInformation'); + } + const toUpdate = {}; + const timestamp = new Date(); + + /** + * Switch statement for encryption types. + */ + let result; + switch (splitData[0]) { + case '1': + result = exports.decryptDataV1(toDecrypt); + if (_.isObject(result)) { + return exports.createError( + 2, + ('Account ' + accountID + ', ' + accountField + ': ' + result.message), + 'utils.checkAccountInformation'); + } + + /** + * Information decrypted. Upgrade version if appropriate. + */ + if (accountID) { + toUpdate[accountField] = exports.encryptDataV3(result, clientKey, clientId); + toUpdate.LastUpdate = timestamp; + mainDB.updateObject(mainDB.collectionAccount, {_id: mongodb.ObjectID(accountID)}, { + $set: toUpdate, + $inc: {LastVersion: 1} + }, + {upsert: false}, false, (error) => { + if (error) { + log.system( + 'ERROR', + ('Error writing to database. Could not upgrade Account ' + accountID + ', ' + accountField + '.'), + 'utils.checkAccountInformation', + '', + 'System', + '127.0.0.1'); + } + + /** + * Success. + */ + log.system( + 'INFO', + ('Encryption upgraded for Account ' + accountID + ', ' + accountField + '.'), + 'utils.checkAccountInformation', + '', + 'System', + '127.0.0.1'); + }); + } + break; + case '3': + result = exports.decryptDataV3(toDecrypt, clientKey, clientId); + if (_.isObject(result)) { + return exports.createError( + 3, + ('Account ' + accountID + ', ' + accountField + ': ' + result.message), + 'utils.checkAccountInformation'); + } + break; + default: + return exports.createError( + 4, + ('Account ' + accountID + ', ' + accountField + ': Encrypted using an unknown method x::'), + 'utils.checkAccountInformation'); + } + + /** + * Check expiry date if appropriate. + */ + if (accountField === 'CardExpiryEncrypted') { + result = exports.checkCardExpiry(result, timestamp); + if (result) { + return exports.createError(5, ('Account ' + accountID + ', ' + accountField + ': ' + result)); + } + } + + /** + * Success! User can access this data. + */ + return null; +}; + +/** + * This function forces the use of HTTPS if the correct switch is enabled (config.useHTTPS == true). + * The reason for this is that the load balancer strips the encryption and sends it as an HTTP request. + * + * @type {Function} isLBHTTPS + * @param {!object} req - The request object. + * @param {!object} res - The response object. + * @returns {!boolean} returns true if the code should proceed. False indicates the code should break (res has been sent). + */ +exports.isLBHTTPS = function(req, res) { + if (config.useHTTPS) { + if (!req.secure) { + res.redirect('https://' + req.get('host') + req.url); + return false; + } + } + + /** + * All good. Proceed. + */ + return true; +}; diff --git a/node_server/ComServe/valid.js b/node_server/ComServe/valid.js new file mode 100644 index 0000000..fafddbb --- /dev/null +++ b/node_server/ComServe/valid.js @@ -0,0 +1,335 @@ +// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Comcarde Node.js General Functionality +// Provides -Bridge- pay functionality. +// Copyright 2015 Comcarde +// Written by Keith Symington and Richard Vanneck +// Refactored 17-9-2015 by KJS +// Largely replaced by JSON Schema validators Oct 2016 by RJT +// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Includes +const _ = require('lodash'); + +const utils = require(global.pathPrefix + 'utils.js'); +const config = require(global.configFile); +const debug = require('debug')('validation:invoices'); + +/** + * Declare the exports at the top so we know what functions are used externally + */ +module.exports = { + checkInt, + checkDP, + validateFieldTimeStamp, + validateFieldHMAC, + validateRedeemPayCode, + validateFieldMerchantInvoice +}; + +/** + * Checks over a string for a whitelisted set of characters. + * + * @type {Function} checkWhitelist + * @param {!string} inputString - The string to be checked for valid characters. + * @param {!string} whitelist - The list of valid charactes as a string 'abcde...'. Also accepts RegEx 'a-z'. + * @param {!int} offset - Added to the index error position: for reporting only. The default should be 0. + * @returns {null | string} Returns either null for success or an error string if there was a problem. + */ +function checkWhitelist(inputString, whitelist, offset) { + const regexString = '[^' + whitelist + ']'; // Wrap the whitelist with not operator, and repeat 0-many times. + const regex = new RegExp(regexString); // No need for global - first invalid char is fine. + const index = regex.exec(inputString); + if (index) { + return ('Error at index [' + (index.index + offset) + '], [' + index[0] + '].'); + } else { + return null; + } +} + +/** + * Checks whether a variable is a safe integer or not. + * Used externally by utils.js to check the config values loaded from the data. + * + * @param {any} input - the item to be tested + * + * @returns {string | null} - 'null' to confirm it is an Integer or a string if it is not. + */ +function checkInt(input) { + if (_.isSafeInteger(input)) { + return null; + } else { + return 'Not a number.'; + } +} + +/** + * Checks whether a variable is in ISO date format (zulu). + * input must be length checked before passing at 24 chars. + * Note that this function seems to be excessive versus a RegEx but we have had problems + * with the results so it was taken back to basics so that the code is reliable if ported. + * + * @param {string} input - the string to test + * @returns {string | null} - Returns 'null' to confirm it is valid or a string if it is not. + */ +/* eslint-disable complexity */ +function checkDateTime(input) { + // Legacy code so cyclomatic comlexity is high. + // Local variables. + let output; + + // Check items. + output = checkWhitelist(input.substr(0, 4), utils.numeric, 0); + if (output) { + return ('Invalid year. ' + output); + } + if (input.charAt(4) !== '-') { + return ('Invalid separator. Error at index [4].'); + } + output = checkWhitelist(input.substr(5, 2), utils.numeric, 5); + if (output) { + return ('Invalid month. ' + output); + } + if (input.charAt(7) !== '-') { + return ('Invalid separator. Error at index [7].'); + } + output = checkWhitelist(input.substr(8, 2), utils.numeric, 8); + if (output) { + return ('Invalid day. ' + output); + } + if (input.charAt(10) !== 'T') { + return ('Invalid time indicator. Error at index [10].'); + } + output = checkWhitelist(input.substr(11, 2), utils.numeric, 11); + if (output) { + return ('Invalid hours. ' + output); + } + if (input.charAt(13) !== ':') { + return ('Invalid separator. Error at index [13].'); + } + output = checkWhitelist(input.substr(14, 2), utils.numeric, 14); + if (output) { + return ('Invalid minutes. ' + output); + } + if (input.charAt(16) !== ':') { + return ('Invalid separator. Error at index [16].'); + } + output = checkWhitelist(input.substr(17, 2), utils.numeric, 17); + if (output) { + return ('Invalid seconds. ' + output); + } + if (input.charAt(19) !== '.') { + return ('Invalid separator. Error at index [19].'); + } + output = checkWhitelist(input.substr(20, 3), utils.numeric, 20); + if (output) { + return ('Invalid milliseconds. ' + output); + } + if (input.charAt(23) !== 'Z') { + return ('Invalid time zone. Error at index [23].'); + } + + // Success! + return null; +} +/* eslint-enable complexity */ + +/** + * Checks the number of dp after the decimal point and returns this number. + * Used by mainDB.js to validate the number of decimal places in coordinates + * + * @type {Function} checkDP + * @param {!number} numberToCheck - The number to process. + * + * @returns {number} The number of decimal places + */ +function checkDP(numberToCheck) { + const tempString = String(numberToCheck); + if (0 > tempString.indexOf('.')) { + return 0; + } + const pieces = tempString.split('.'); + return pieces[1].length; +} + +// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Common field validations. +// All return null for success or a string otherwise. +// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Validates that the provided HMAC is in the correct format. + * Used externally by auth.js to validate bridge-hmac + * + * @param {string} input - the string to test + * @returns {string | null} - error string on error, or null on success + */ +function validateFieldHMAC(input) { + if (!_.isString(input)) { + return 'Identifier not a string.'; + } + if (input.length !== (config.HMACBytes * 2)) { + return ('Identifier length not ' + (config.HMACBytes * 2) + '.'); + } + const output = checkWhitelist(input, utils.lowerCaseHex, 0); + if (output) { + return ('Invalid characters in HMAC. ' + output); + } + return null; +} + +/** + * Validates that timestamps are correct. + * Used externally by auth.js to validate bridge-timestamp + * + * @param {any} input - the item to validate + * + * @returns {string | null} A string detailing any error, or null if no errors found. + */ +function validateFieldTimeStamp(input) { + if (!_.isString(input)) { + return 'TimeStamp not a string.'; + } + if (input.length !== 24) { + return 'TimeStamp should be 24 characters long.'; + } + const output = checkDateTime(input); + if (output) { + return ('TimeStamp not in ISO date format. ' + output); + } + return null; +} + +/** + * Validates the functional correctness of a MerchantInvoice: + * - No rows with the same Item_ID + * - Check that the Net, VAT, Quantity, and Total match up in a row + * - Check that the rows total the requested overall TotalAmount + * + * @param {Object[]} input - array of merchantInvoice line items + * @param {number} expectedTotal - the overall total the line items should match + * @param {boolean} allowRepeatItems - true to allow the same Item_ID to appear multiple times + * + * @returns {string | null} - an error string if any errors are found, or null if no errors + */ +function validateFieldMerchantInvoice(input, expectedTotal, allowRepeatItems) { + const items = {}; + let cumulativeTotal = 0; + + // Iterate all line items. + for (let counter = 0; counter < input.length; counter++) { + const line = input[counter]; + if (line.Item_ID && !allowRepeatItems) { + /** + * Check for duplicate Item_IDs. + */ + if (items.hasOwnProperty(line.Item_ID)) { + return 'Invalid body.MerchantInvoice[' + counter + ']: ' + + 'Duplicate Item_ID in MerchantInvoice.'; + } else { + items[line.Item_ID] = true; + } + } + + /** + * Check the calculated per-line vat and total amounts are correct, + * depending on whether the base price is on a Net or Gross basis. + */ + let expectedLineTotal = -1; + let expectedVatTotal = -1; + if (_.isInteger(line.Item_NetAmount) && _.isNull(line.Item_GrossAmount)) { + /** + * This is a NET line item + */ + const netTotal = _.round(line.Item_NetAmount * line.Item_Quantity); + const vatMultiplier = line.Item_VATRate / (100 * 100); + expectedVatTotal = _.round(netTotal * vatMultiplier); + expectedLineTotal = netTotal + expectedVatTotal; + debug( + 'NET:', + line.Item_NetAmount, line.Item_Quantity, line.Item_VATRate + ); + debug( + 'Interim:', + netTotal, vatMultiplier + ); + } else if (_.isNull(line.Item_NetAmount) && _.isInteger(line.Item_GrossAmount)) { + /** + * This is a GROSS line item + */ + expectedLineTotal = _.round(line.Item_GrossAmount * line.Item_Quantity); + const vatMultiplier = line.Item_VATRate / (100 * 100); + expectedVatTotal = _.round(expectedLineTotal - (expectedLineTotal / (1 + vatMultiplier))); + + debug( + 'GROSS:', + line.Item_GrossAmount, line.Item_Quantity, line.Item_VATRate + ); + } else { + /** + * Both are set (or both null) which is wrong + */ + return 'Invalid body.MerchantInvoice[' + counter + ']: ' + + 'Only 1 of NetAmount and GrossAmount should be set (with the other null)'; + } + + debug( + '-- Actual: ', + line.Line_VATAmount, line.Line_TotalAmount + ); + debug( + '-- Expect: ', + expectedVatTotal, expectedLineTotal + ); + + if (expectedVatTotal !== line.Line_VATAmount) { + return 'Invalid body.MerchantInvoice[' + counter + ']: ' + + 'Line_VATAmount incorrect.'; + } + + if (expectedLineTotal !== line.Line_TotalAmount) { + return 'Invalid body.MerchantInvoice[' + counter + ']: ' + + 'Line_TotalAmount incorrect.'; + } + + // Record total. + cumulativeTotal += line.Line_TotalAmount; + } + + // Check total. + if (expectedTotal !== cumulativeTotal) { + return 'Invalid body.MerchantInvoice: Cumulative total does not match RequestAmount.'; + } + + // Success. + return null; +} + +/** + * Provides additional function validation of the RedeemPaycode command. In + * particular, it validates that the MerchantInvoice values are consistent, + * both per-row and compared to the overall total. + * + * @param {Object} inputInfo - the input to be functionally validated + * + * @returns {Object|null} - An object to return to the user on error, or null if no errors found + */ +function validateRedeemPayCode(inputInfo) { + // Key and token check. + const errorcode = 174; + let output; + + if ('MerchantInvoice' in inputInfo) { + output = validateFieldMerchantInvoice( + inputInfo.MerchantInvoice, + inputInfo.RequestAmount, + false + ); + if (output) { + debug('--- Error:', output); + return utils.createError(errorcode, output); + } + } + + // All valid. + return null; +} diff --git a/node_server/ComServe/worldpay.js b/node_server/ComServe/worldpay.js new file mode 100644 index 0000000..2cc361c --- /dev/null +++ b/node_server/ComServe/worldpay.js @@ -0,0 +1,170 @@ +/** + * @fileOverview Node.js Worldpay Acquiring Code for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ + +/** + * Includes needed for this module. + */ +var request = require('request'); +var log = require(global.pathPrefix + 'log.js'); +var _ = require('lodash'); +var config = require(global.configFile); +var sms = require(global.pathPrefix + 'sms.js'); + +exports.useMCC6012 = 0; // Add MCC information where appropriate. +exports.useAVS = 1; // Use AVS on tokenisation. +exports.worldpayPostData = 1; // Shows the info sent to Worldpay. +exports.primaryFailedComms = 0; // Ticks up every time communications fail with Worldpay's primary server. +exports.worldpaySMSAlertSent = 0; // Designed to prevent multiple SMS calls. +exports.worldpayTimeout = 25000; // Apps use 30 seconds as we need a little time to respond to them. + +/** + * Worldpay API function call. + * + * @type {function} worldpayFunction + * @param {String} method - http method: 'GET', 'POST', etc. + * @param {?string} urlPath - Additional path information - e.g. tokens, order etc. Alternatively use null. + * @param {?string} authKey - Add the key string here if a key needs to be used. Alternatively use null. + * @param {?object} additionalHeaders - JSON object with any additional headers. null if none needed. + * @param {!object} postBody - JSON body for the request data in the main packet. null if none needed. + * @param {!function} callback - Function to call when processing complete.. + */ +exports.worldpayFunction = function(method, urlPath, authKey, additionalHeaders, postBody, callback) { + /** + * Set the default headers. + */ + var postHeaders = {}; + _.merge(postHeaders, {'User-Agent': 'Super Agent/0.0.1', 'Content-type': 'application/json'}); + /** + * Add the auth key if it exists. + */ + if (authKey !== null) { + postHeaders.Authorization = authKey; + } + /** + * Add any additional headers. + */ + if (additionalHeaders !== null) { + _.merge(postHeaders, additionalHeaders); + } + + // Show the data to be posted. Never post this information on the live server. + if (exports.worldpayPostData && config.isDevEnv) { + log.system( + 'INFO', + ('[OUT] headers: ' + JSON.stringify(postHeaders) + ' body: ' + JSON.stringify(postBody)), + 'worldpay.worldpayFunction', + '', + 'System', + '127.0.0.1'); + } + + /** + * Process the data by submitting it to Worldpay. + * The assumption is that there must be data in the body to proceed. + */ + if (postBody) { + /** + * Configure the request. + */ + var location = config.worldpayPrimaryGateway; + if (urlPath !== null) { + location += urlPath; + } + var options = { + url: location, + method: method, + headers: postHeaders, + json: true, + strictSSL: true, + body: postBody, + timeout: exports.worldpayTimeout + }; + + /** + * Start the request. + */ + request(options, function(error, response, body) { + /** + * Handle the response appropriately. + */ + var worldpayResult = ''; + if (!error && (response.statusCode === 200)) { + /** + * A response was received. 200 means the result was successful. + */ + worldpayResult = body; + return callback(null, worldpayResult); + } else if (!error && (response.statusCode !== 200)) { + /** + * A response was received. It's not 200 so it's an error. + * The important values are: + * https://developer.worldpay.com/jsonapi/api#errors + */ + worldpayResult = body; + return callback(worldpayResult); + } else { + /** + * Return a request error. + */ + return callback('Error: ' + error); + } + }); + } else { + return callback('No data to process.'); + } +}; + +/** + * This function deals with Worldpay communication failures. Currently there is no switchover gateway. + * + * @type {function} commsFailure + * @param {!string} source - The function where the error was called from e.g. 'AddCard.process'. + */ +exports.commsFailure = function(source) { + /** + * General error - usually indicates Worldpay is down. + */ + if (exports.primaryFailedComms >= (config.worldpayNotificationThreshold - 1)) { + /** + * Inform admins as it is over the threshold. + */ + if (exports.worldpaySMSAlertSent === 0) { + /** + * Block multiple SMS messages and send a single one to the admin(s). + * Note that SMS is blocked before we know it has been sent; the callback structure means that this could + * be initialised hundreds of times on a loaded system before the first one returned. + */ + exports.worldpaySMSAlertSent = 1; + sms.sendSMS(null, (sms.adminMobile + ',' + sms.backupMobile), + config.worldpayPrimaryGatewayFailure, function(err, smsBalance) { + if (err) { + log.system( + 'ERROR', + 'Unable to send SMS.', + source, + '', + 'System', + '127.0.0.1'); + return; + } + + /** + * Success. + */ + log.system( + 'INFO', + ('Worldpay primary gateway failure. SMS sent to admins (SMS balance now ' + smsBalance + ').'), + source, + '', + 'System', + '127.0.0.1'); + }); + } + } else { + exports.primaryFailedComms += 1; + } +}; diff --git a/node_server/WebApp/defaultCompanyLogo0.png b/node_server/WebApp/defaultCompanyLogo0.png new file mode 100644 index 0000000..401cfe4 Binary files /dev/null and b/node_server/WebApp/defaultCompanyLogo0.png differ diff --git a/node_server/WebApp/defaultSelfie.png b/node_server/WebApp/defaultSelfie.png new file mode 100644 index 0000000..401cfe4 Binary files /dev/null and b/node_server/WebApp/defaultSelfie.png differ diff --git a/node_server/WebApp/favicon.ico b/node_server/WebApp/favicon.ico new file mode 100644 index 0000000..151bdf6 Binary files /dev/null and b/node_server/WebApp/favicon.ico differ diff --git a/node_server/WebApp/icons/AMEX.png b/node_server/WebApp/icons/AMEX.png new file mode 100644 index 0000000..0541971 Binary files /dev/null and b/node_server/WebApp/icons/AMEX.png differ diff --git a/node_server/WebApp/icons/BRIDGE_MERCHANT.png b/node_server/WebApp/icons/BRIDGE_MERCHANT.png new file mode 100644 index 0000000..b468634 Binary files /dev/null and b/node_server/WebApp/icons/BRIDGE_MERCHANT.png differ diff --git a/node_server/WebApp/icons/CARTEBLEUE.png b/node_server/WebApp/icons/CARTEBLEUE.png new file mode 100644 index 0000000..6cbad3c Binary files /dev/null and b/node_server/WebApp/icons/CARTEBLEUE.png differ diff --git a/node_server/WebApp/icons/DINERS.png b/node_server/WebApp/icons/DINERS.png new file mode 100644 index 0000000..d12101a Binary files /dev/null and b/node_server/WebApp/icons/DINERS.png differ diff --git a/node_server/WebApp/icons/Dankort.png b/node_server/WebApp/icons/Dankort.png new file mode 100644 index 0000000..4b9cefd Binary files /dev/null and b/node_server/WebApp/icons/Dankort.png differ diff --git a/node_server/WebApp/icons/Diners-Generic.png b/node_server/WebApp/icons/Diners-Generic.png new file mode 100644 index 0000000..599a323 Binary files /dev/null and b/node_server/WebApp/icons/Diners-Generic.png differ diff --git a/node_server/WebApp/icons/Discover-card.png b/node_server/WebApp/icons/Discover-card.png new file mode 100644 index 0000000..ddeff9d Binary files /dev/null and b/node_server/WebApp/icons/Discover-card.png differ diff --git a/node_server/WebApp/icons/Electron.png b/node_server/WebApp/icons/Electron.png new file mode 100644 index 0000000..bde26f2 Binary files /dev/null and b/node_server/WebApp/icons/Electron.png differ diff --git a/node_server/WebApp/icons/Generic-card.png b/node_server/WebApp/icons/Generic-card.png new file mode 100644 index 0000000..db826a3 Binary files /dev/null and b/node_server/WebApp/icons/Generic-card.png differ diff --git a/node_server/WebApp/icons/JCB.png b/node_server/WebApp/icons/JCB.png new file mode 100644 index 0000000..8211023 Binary files /dev/null and b/node_server/WebApp/icons/JCB.png differ diff --git a/node_server/WebApp/icons/LloydsTSB.png b/node_server/WebApp/icons/LloydsTSB.png new file mode 100644 index 0000000..5cb02d6 Binary files /dev/null and b/node_server/WebApp/icons/LloydsTSB.png differ diff --git a/node_server/WebApp/icons/MASTERCARD_CORPORATE_CREDIT.png b/node_server/WebApp/icons/MASTERCARD_CORPORATE_CREDIT.png new file mode 100644 index 0000000..28d107c Binary files /dev/null and b/node_server/WebApp/icons/MASTERCARD_CORPORATE_CREDIT.png differ diff --git a/node_server/WebApp/icons/MASTERCARD_CORPORATE_DEBIT.png b/node_server/WebApp/icons/MASTERCARD_CORPORATE_DEBIT.png new file mode 100644 index 0000000..f2b5f24 Binary files /dev/null and b/node_server/WebApp/icons/MASTERCARD_CORPORATE_DEBIT.png differ diff --git a/node_server/WebApp/icons/MASTERCARD_CREDIT.png b/node_server/WebApp/icons/MASTERCARD_CREDIT.png new file mode 100644 index 0000000..e192fce Binary files /dev/null and b/node_server/WebApp/icons/MASTERCARD_CREDIT.png differ diff --git a/node_server/WebApp/icons/MASTERCARD_DEBIT.png b/node_server/WebApp/icons/MASTERCARD_DEBIT.png new file mode 100644 index 0000000..ea1d4f1 Binary files /dev/null and b/node_server/WebApp/icons/MASTERCARD_DEBIT.png differ diff --git a/node_server/WebApp/icons/MIR.png b/node_server/WebApp/icons/MIR.png new file mode 100644 index 0000000..2e73a4c Binary files /dev/null and b/node_server/WebApp/icons/MIR.png differ diff --git a/node_server/WebApp/icons/Maestro.png b/node_server/WebApp/icons/Maestro.png new file mode 100644 index 0000000..e8e5780 Binary files /dev/null and b/node_server/WebApp/icons/Maestro.png differ diff --git a/node_server/WebApp/icons/RBS.png b/node_server/WebApp/icons/RBS.png new file mode 100644 index 0000000..2f87ece Binary files /dev/null and b/node_server/WebApp/icons/RBS.png differ diff --git a/node_server/WebApp/icons/VISA_CORPORATE_CREDIT.png b/node_server/WebApp/icons/VISA_CORPORATE_CREDIT.png new file mode 100644 index 0000000..3073a0e Binary files /dev/null and b/node_server/WebApp/icons/VISA_CORPORATE_CREDIT.png differ diff --git a/node_server/WebApp/icons/VISA_CORPORATE_DEBIT.png b/node_server/WebApp/icons/VISA_CORPORATE_DEBIT.png new file mode 100644 index 0000000..9c17d8f Binary files /dev/null and b/node_server/WebApp/icons/VISA_CORPORATE_DEBIT.png differ diff --git a/node_server/WebApp/icons/VISA_CREDIT.png b/node_server/WebApp/icons/VISA_CREDIT.png new file mode 100644 index 0000000..3073a0e Binary files /dev/null and b/node_server/WebApp/icons/VISA_CREDIT.png differ diff --git a/node_server/WebApp/icons/VISA_DEBIT.png b/node_server/WebApp/icons/VISA_DEBIT.png new file mode 100644 index 0000000..9c17d8f Binary files /dev/null and b/node_server/WebApp/icons/VISA_DEBIT.png differ diff --git a/node_server/WebApp/icons/bridge-card.png b/node_server/WebApp/icons/bridge-card.png new file mode 100644 index 0000000..32af36b Binary files /dev/null and b/node_server/WebApp/icons/bridge-card.png differ diff --git a/node_server/WebApp/icons/credorax-account.png b/node_server/WebApp/icons/credorax-account.png new file mode 100644 index 0000000..0450c2e Binary files /dev/null and b/node_server/WebApp/icons/credorax-account.png differ diff --git a/node_server/WebApp/icons/worldpay-account.png b/node_server/WebApp/icons/worldpay-account.png new file mode 100644 index 0000000..7107261 Binary files /dev/null and b/node_server/WebApp/icons/worldpay-account.png differ diff --git a/node_server/dev_api/common/HttpError.js b/node_server/dev_api/common/HttpError.js new file mode 100644 index 0000000..21246c6 --- /dev/null +++ b/node_server/dev_api/common/HttpError.js @@ -0,0 +1,23 @@ +// extends the Error object + +module.exports = class HttpError extends Error { + /** + * Creates a HttpError Object with inheritance + * + * @param {number} httpCode generic respresentation of the error + * @param {string} internalName specific represention of the error + * @param {?Object} payload generally used for the outgoing body + */ + constructor(httpCode, internalName, payload) { + if (!Number.isInteger(httpCode) || Math.sign(httpCode) !== 1) { + throw new TypeError('First argument must be a positive integer'); + } + // eslint-disable-next-line lodash/prefer-lodash-typecheck + if (typeof internalName !== 'string') { + throw new TypeError('Second argument must be a string'); + } + super(internalName); + this.httpCode = httpCode; + this.httpPayload = payload; + } +}; diff --git a/node_server/dev_api/common/HttpError.spec.js b/node_server/dev_api/common/HttpError.spec.js new file mode 100644 index 0000000..4a35ae7 --- /dev/null +++ b/node_server/dev_api/common/HttpError.spec.js @@ -0,0 +1,38 @@ +'use strict'; + +const HttpError = require('./HttpError'); +const chai = require('chai'); + +const expect = chai.expect; + +describe('HttpError', () => { + it('is an instance of Error', () => { + expect(new HttpError(400, 'Unknown')).to.be.instanceof(Error); + }); + + it('First argument must be a positive integer', () => { + expect(() => new HttpError(0, 'unknown')).to.throw(); + expect(() => new HttpError(-1, 'unknown')).to.throw(); + expect(() => new HttpError('500', 'unknown')).to.throw(); + expect(() => new HttpError(500, 'unknown')).to.not.throw(); + }); + + it('Second argument must be a string', () => { + expect(() => new HttpError(500, 200)).to.throw(); + expect(() => new HttpError(500, {})).to.throw(); + expect(() => new HttpError(500, true)).to.throw(); + expect(() => new HttpError(500, 'unknown')).to.not.throw(); + }); + + it('Saves httpCode and info onto the instance', () => { + const obj = {}; + const error = new HttpError(123, 'Unknown', obj); + expect(error.httpCode).to.equal(123); + expect(error.httpPayload).to.equal(obj); + }); + + it('The internal respresentation transfers to the native Error object', () => { + const error = new HttpError(123, 'ABCDEF'); + expect(error.toString().includes('ABCDEF')).to.equal(true); + }); +}); diff --git a/node_server/dev_api/common/daoFactory.js b/node_server/dev_api/common/daoFactory.js new file mode 100644 index 0000000..062b2b0 --- /dev/null +++ b/node_server/dev_api/common/daoFactory.js @@ -0,0 +1,142 @@ +'use strict'; + +/** + * The purpose of this module is to insulate code from main.db + * and supply a non specific interface to any DB. Granted, the interface + * is mongoDB specific but it can be easily duplicated for + * other DBMS (and there are libraries which do just that). + * + * This file must NEVER mention specific collection/db names. + * This file must NEVER mention specific collection/db fields + */ + +const mainDB = require(global.pathPrefix + 'mainDB'); +const {ObjectId} = require('mongodb'); +const db = require('../../ComServe/mainDB-promises'); +const HttpError = require('./HttpError'); +const {INSERT, QUERY} = require('./errorDicts/daoFactory.json'); + +/** + * Gets the mongoDB object since it'll change after file import + * + * @private + * @param {string} collectionName as stored on mainDb export + * @returns {MongoCollection} + */ +function getMongoCollection(collectionName) { + const mongoDBCollection = mainDB[collectionName]; + return mongoDBCollection; +} + +/** + * Common INSERT error handler + * + * @private + * @param {Object} error + * @returns + */ +function throwQueryError(error) { + const err = new HttpError(QUERY.httpCode, QUERY.internal, QUERY.external); + err.additionalInfo = error; + throw err; +} + +/** + * Common QUERY error handler + * + * @private + * @param {Object} error + * @returns + */ +function throwInsertError(error) { + const err = new HttpError(INSERT.httpCode, INSERT.internal, INSERT.external); + err.additionalInfo = error; + throw err; +} + +/** + * DAO factory for standardised access + * + * @param {string} collection name + * @returns {Object} + */ +function daoFactory(collection) { + // eslint-disable-next-line lodash/prefer-lodash-typecheck + if (typeof collection !== 'string') { + throw new TypeError('First argument must be of type string'); + } + + const factory = { + + /** + * Stores an instrument. Note this is not instrument specific. + * + * @param {Object} data to be stored + * @returns {Promise} mongoDB _id + */ + createOne: (data) => db + .addObject(getMongoCollection(collection), data, undefined, false) + .then((result) => result[0]._id.toString()) + .catch((error) => throwInsertError(error)), + + /** + * Retreives an item by it's I.D + * + * @param {string} id to find + * @param {!Object} projection fields to pull + * @returns {Promise} + */ + getOneByUUID: (id, projection) => factory + .getOneByQuery( + {_id: ObjectId(id)}, + Object.assign(projection || {}, {_id: 0}) + ), + + /** + * Retreives one or more items by a I.D + * + * @param {string} id to find + * @param {!Object} projection fields to pull + * @returns {Promise} + */ + getByUUID: (id, projection) => factory + .getByQuery( + {_id: ObjectId(id)}, + Object.assign(projection || {}, {_id: 0}) + ), + + /** + * Retreives one or more items by a query + * + * @param {Object} query to run against + * @param {!Object} projection specific fields + * @returns {Promise} + */ + getByQuery: (query, projection) => getMongoCollection(collection) + .find(query, projection) + .toArray() + .catch((error) => throwQueryError(error)), + + /** + * Retreives an item by a query. + * _id is always translated to Mongo's ObjectId() object. + * + * @param {Object} query to run against + * @returns {Promise} + */ + getOneByQuery: (query) => { + const cp = JSON.parse(JSON.stringify(query)); + if (cp._id) { + cp._id = ObjectId(cp._id); + } + return db + .findOneObject(getMongoCollection(collection), cp, undefined, false) + .catch((error) => throwQueryError(error)); + } + }; + + return factory; +} + +module.exports = daoFactory; + diff --git a/node_server/dev_api/common/daoFactory.spec.js b/node_server/dev_api/common/daoFactory.spec.js new file mode 100644 index 0000000..611c36b --- /dev/null +++ b/node_server/dev_api/common/daoFactory.spec.js @@ -0,0 +1,170 @@ +/* eslint-disable import/no-unassigned-import */ +/* eslint-disable mocha/no-hooks-for-single-case */ +/* eslint max-nested-callbacks: ["error", 99] */ + +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +require('../../tools/test/testGlobals'); + +const daoFactory = require('./daoFactory'); +const db = require('../../ComServe/mainDB-promises'); +const dbMain = require('../../ComServe/mainDB'); +const {ObjectId} = require('mongodb'); + +const {expect} = chai; +chai.use(sinonChai); + +const fakeCollection = dbMain.collectionFakename = { + find: () => {} +}; + +const dao = daoFactory('collectionFakename'); + +describe('daoFactory', () => { + it('requires a collection name', () => { + return expect(() => daoFactory()).to.throw(); + }); + + it('creates a factory', () => { + expect(dao).to.be.a('object'); + }); + + describe('functions', () => { + describe('createOne', () => { + afterEach(() => { + db.addObject.restore(); + }); + beforeEach(() => { + sinon.stub(db, 'addObject').resolves([{ + _id: 'uuid' + }]); + }); + it('pass args correctly', () => dao.createOne(566) + .then(() => { + expect(db.addObject) + .to.be + .calledWith(fakeCollection, 566, undefined, false); + return null; + })); + + it('maps data correctly', () => dao.createOne(566) + .then((uuid) => { + expect(uuid).to.be.a('string'); + return null; + })); + }); + + describe('getByQuery', () => { + afterEach(() => { + fakeCollection.find.restore(); + }); + beforeEach(() => { + sinon.stub(fakeCollection, 'find').returns({ + toArray: () => Promise.resolve([]) + }); + }); + it('pass args correctly', () => dao.getByQuery({id: 'abc'}, 10) + .then(() => { + return expect(fakeCollection.find) + .to.be + .calledWith({id: 'abc'}, 10); + })); + }); + + describe('getOneByQuery', () => { + afterEach(() => { + db.findOneObject.restore(); + }); + describe('success', () => { + beforeEach(() => { + sinon.stub(db, 'findOneObject').resolves({ + _id: 456 + }); + }); + it('pass args correctly with ObjectId translation', () => dao.getOneByQuery({_id: '507f191e810c19729de860ea'}) + .then(() => { + return expect(db.findOneObject) + .to.be + .calledWith(fakeCollection, {_id: ObjectId('507f191e810c19729de860ea')}, undefined, false); + })); + + it('maps data correctly', () => dao.getOneByQuery({id: 'abc'}) + .then((result) => { + return expect(result).to.be.a('object'); + })); + }); + + describe('fail', () => { + before(() => { + sinon.stub(db, 'findOneObject').resolves(null); + }); + it('returns null if not found', () => dao.getOneByQuery({id: 'abc'}) + .then((result) => { + return expect(result).to.equal(null); + })); + }); + }); + + describe('getOneByUUID', () => { + afterEach(() => { + dao.getOneByQuery.restore(); + }); + describe('calls getOneByQuery', () => { + const result = { + _id: '5a859bf57541844a026c6208' + }; + + beforeEach(() => { + sinon.stub(dao, 'getOneByQuery').resolves(result); + }); + it('pass args and adds _id to empty projection', () => dao.getOneByUUID('5a859bf57541844a026c6208') + .then(() => { + return expect(dao.getOneByQuery) + .to.be + .calledWith({_id: ObjectId('5a859bf57541844a026c6208')}, {_id: 0}); + })); + + it('pass args and adds _id to projection', () => dao.getOneByUUID('5a859bf57541844a026c6208', {foo: true}) + .then(() => { + return expect(dao.getOneByQuery) + .to.be + .calledWith({_id: ObjectId('5a859bf57541844a026c6208')}, { + foo: true, + _id: 0 + }); + })); + it('maps data correctly', () => dao.getOneByUUID('5a859bf57541844a026c6208') + .then((r2) => { + return expect(r2).to.equal(result); + })); + }); + }); + + describe('getByUUID', () => { + afterEach(() => { + dao.getByQuery.restore(); + }); + describe('calls getByQuery', () => { + const result = []; + + beforeEach(() => { + sinon.stub(dao, 'getByQuery').resolves(result); + }); + it('pass args correctly', () => dao.getByUUID('5a859bf57541844a026c6208') + .then(() => { + return expect(dao.getByQuery) + .to.be + .calledWith({_id: ObjectId('5a859bf57541844a026c6208')}, {_id: 0}); + })); + + it('maps data correctly', () => dao.getByUUID('5a859bf57541844a026c6208') + .then((r2) => { + return expect(r2).to.equal(result); + })); + }); + }); + }); +}); diff --git a/node_server/dev_api/common/errorDicts/daoFactory.json b/node_server/dev_api/common/errorDicts/daoFactory.json new file mode 100644 index 0000000..ff992e6 --- /dev/null +++ b/node_server/dev_api/common/errorDicts/daoFactory.json @@ -0,0 +1,18 @@ +{ + "INSERT": { + "internal": "BRIDGE: DB INSERT FAILURE", + "httpCode": 502, + "external": { + "description": "DB Failure.", + "code": 800 + } + }, + "QUERY": { + "internal": "BRIDGE: DB QUERY FAILURE", + "httpCode": 502, + "external": { + "description": "DB Failure.", + "code": 801 + } + } +} diff --git a/node_server/dev_api/common/errorDicts/instruments.json b/node_server/dev_api/common/errorDicts/instruments.json new file mode 100644 index 0000000..db1233d --- /dev/null +++ b/node_server/dev_api/common/errorDicts/instruments.json @@ -0,0 +1,42 @@ +{ + "INVALID": { + "internal": "BRIDGE: INVALID INSTRUMENT", + "httpCode": 404, + "external": { + "description": "The instrument could not be found, has no access or has expired.", + "code": 600 + } + }, + "ENCRYPTION_FAIL": { + "internal": "BRIDGE: INSTRUMENT ENCRYPTION FAILURE", + "httpCode": 500, + "external": { + "description": "Instrument processing failure.", + "code": 601 + } + }, + "DECRYPTION_FAIL": { + "internal": "BRIDGE: INSTRUMENT DECRYPTION FAILURE", + "httpCode": 401, + "external": { + "description": "The instrument could not be decrypted.", + "code": 602 + } + }, + "INVALID_EXPIRY_DATE": { + "internal": "BRIDGE: INVALID EXPIRY DATE", + "httpCode": 400, + "external": { + "description": "The expiry date supplied was invalid.", + "code": 603 + } + }, + "INVALID_START_DATE": { + "internal": "BRIDGE: INVALID START DATE", + "httpCode": 400, + "external": { + "description": "The start date supplied was invalid.", + "code": 604 + } + } +} diff --git a/node_server/dev_api/common/hashString.js b/node_server/dev_api/common/hashString.js new file mode 100644 index 0000000..da51f1e --- /dev/null +++ b/node_server/dev_api/common/hashString.js @@ -0,0 +1,25 @@ +module.exports = { + hashString +}; + +const crypto = require('crypto'); + +/** + * Hashes a plain text string + * + * @param {string} plainString - plain text string + * @returns {Promise} hash + */ +function hashString(plainString) { + return new Promise((resolve) => { + const hasher = crypto.createHash('sha256'); + hasher.setEncoding('hex'); + + hasher.on('readable', () => { + const passwordHash = hasher.read(); + resolve(passwordHash); + }); + + hasher.end(plainString, 'utf8'); + }); +} diff --git a/node_server/dev_api/common/hashString.spec.js b/node_server/dev_api/common/hashString.spec.js new file mode 100644 index 0000000..9c6c34b --- /dev/null +++ b/node_server/dev_api/common/hashString.spec.js @@ -0,0 +1,32 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable mocha/no-hooks-for-single-case */ + +'use strict'; +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +// eslint-disable-next-line import/no-unassigned-import +require('../../tools/test/testGlobals'); + +const hashString = rewire('./hashString'); + +const expect = chai.expect; +chai.use(sinonChai); +chai.use(chaiAsPromised); + +describe('common.hashString', () => { + it('it returns a specific hashed string', () => { + return hashString.hashString('somePlainText') + .then((returnedHashString) => { + return expect(returnedHashString).to.equal('7cd67e99c19bbd6b6da927ad25081fc473e50ff27c86a41fc2160e6824f61492'); + }); + }); + it('returns a Hex string', () => { + return hashString.hashString('somePlainText') + .then((returnedHashString) => { + return expect(returnedHashString).to.match(/^[a-fA-F0-9]+$/g); + }); + }); +}); diff --git a/node_server/dev_api/common/instrument/decrypt-card.js b/node_server/dev_api/common/instrument/decrypt-card.js new file mode 100644 index 0000000..dc8e17f --- /dev/null +++ b/node_server/dev_api/common/instrument/decrypt-card.js @@ -0,0 +1,31 @@ +'use strict'; + +const HttpError = require('../HttpError'); +const {DECRYPTION_FAIL} = require('../errorDicts/instruments.json'); +const encryption = require('../../../utils/encryption'); +const hashString = require('../hashString'); + +/** + * Maps a instrument decryption call result or error + * + * @param {string} encryptedInstrument - instrument data + * @param {string} encryptionKey - encryption key + * @param {string} userId - authentication id + * @returns {Promise} decrypted instrument + */ +function decrypt(encryptedInstrument, encryptionKey, userId) { + // decrypt the instrument + return Promise.resolve() + .then(() => { + return hashString.hashString(encryptionKey).then((hashedKey) => { + // this function should be async for future compatibility + const instrument = encryption.decryptCardMaintainingAccount(encryptedInstrument, hashedKey, userId); + if (!instrument) { + throw new HttpError(DECRYPTION_FAIL.httpCode, DECRYPTION_FAIL.internal, DECRYPTION_FAIL.external); + } + return instrument; + }); + }); +} + +module.exports = {decrypt}; diff --git a/node_server/dev_api/common/instrument/decrypt-card.spec.js b/node_server/dev_api/common/instrument/decrypt-card.spec.js new file mode 100644 index 0000000..492fe89 --- /dev/null +++ b/node_server/dev_api/common/instrument/decrypt-card.spec.js @@ -0,0 +1,94 @@ +'use strict'; + +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const decryptCard = require('./decrypt-card'); + +const {DECRYPTION_FAIL} = require('../errorDicts/instruments.json'); + +const expect = chai.expect; +chai.use(chaiAsPromised); + +/** + * Test Values (with real encryption keys) + */ +/* eslint-disable max-len */ +const TEST_ACCOUNT_ENCRYPTED = { + _id: '5aa66e821552180e8ff24c8a', + AccountType: 'Credit/Debit Payment Card', + ReceivingAccount: 0, + PaymentsAccount: 1, + CreditDebitCardInfo: { + CardPANEncrypted: '3::7c0252083c1c004c3fd273275073c960ecba75271ac5bd1c63b61aa61f31fad9ef69f8623100183376abbec86206bd21233077994da827859aaf645963a3a5de9aca237b7041ea91ca950d1396a3f149', + CardExpiryEncrypted: '3::6d1e37890ef1fa67c16636811b4a8b4cc654066497d20893a4e47494ae03bc39d1a82264131a02f1ae08bd89b0535b9133badb129de6626874b078995b93545c', + CardValidFromEncrypted: '3::38324b602ed6c40a57b7d3da4337bdbfbb2ed0b596c7ecdf14569e585083886a52e1c44ba6f65d760d6d5445646b26360b56722c0519ba26a3a0addc21b7ed2b', + IssueNumberEncrypted: '3::2fd410ba2f1c0a14185110477bf3cee305ea9ad8eb76452a0d95e4f16d7f04abf45b3690f78b501746b6b30081d2842c12b3610547b51fec6c50eae561ee8f73', + BillingAddress: '5aa66e821552180e8ff24c89', + NameOnAccount: 'John E Doe', + CardPAN: '4*** **** **** *111', + Email: 'a@b.com', + FirstName: 'John', + LastName: 'Doe' + }, + UserID: '79a26d981246978135edadf1', + VendorID: 'Visa', + VendorAccountName: 'Credit/Debit Card', + Description: 'BloggsCo Inc. account.', + IconLocation: 'VISA_CREDIT.png', + APIVersion: '7.6.4-dev', + Integrity: null, + LastUpdate: new Date(), + LastVersion: 1 +}; +const TEST_KEY_RIGHT = '8a6a7193-d1d9-4eea-93f0-4cec14142fa0'; +const TEST_KEY_WRONG = '538af3c0-945b-4eaf-bb88-f3a86a17d5fb'; +const USER_ID_RIGHT = '79a26d981246978135edadf1'; +const USER_ID_WRONG = '79a26d981246978135edadf2'; +/* eslint-enable max-len */ + +const TEST_ACCOUNT_DECRYPTED = { + _id: '5aa66e821552180e8ff24c8a', + AccountType: 'Credit/Debit Payment Card', + ReceivingAccount: 0, + PaymentsAccount: 1, + CreditDebitCardInfo: { + IssueNumber: 1, + cardNumber: '4444 3333 2222 1111', + expiryMonth: '01', + expiryYear: '2020', + startMonth: '01', + startYear: '2000', + BillingAddress: '5aa66e821552180e8ff24c89', + NameOnAccount: 'John E Doe', + CardPAN: '4*** **** **** *111', + Email: 'a@b.com', + FirstName: 'John', + LastName: 'Doe' + }, + UserID: '79a26d981246978135edadf1', + VendorID: 'Visa', + VendorAccountName: 'Credit/Debit Card', + Description: 'BloggsCo Inc. account.', + IconLocation: 'VISA_CREDIT.png', + APIVersion: '7.6.4-dev', + Integrity: null, + LastUpdate: new Date(), + LastVersion: 1 +}; + +describe('common.instrument.decrypt-card', () => { + it('it returns decrypted keys if instrument is valid', () => { + return expect(decryptCard.decrypt(TEST_ACCOUNT_ENCRYPTED, TEST_KEY_RIGHT, USER_ID_RIGHT)) + .to.eventually.deep.equal(TEST_ACCOUNT_DECRYPTED); + }); + + it('it throws a "DECRYPTION_FAIL" HttpError if key is wrong', () => { + return expect(decryptCard.decrypt(TEST_ACCOUNT_ENCRYPTED, TEST_KEY_WRONG, USER_ID_RIGHT)) + .to.eventually.be.rejectedWith(DECRYPTION_FAIL); + }); + + it('it throws a "DECRYPTION_FAIL" HttpError if UserID is wrong', () => { + return expect(decryptCard.decrypt(TEST_ACCOUNT_ENCRYPTED, TEST_KEY_RIGHT, USER_ID_WRONG)) + .to.eventually.be.rejectedWith(DECRYPTION_FAIL); + }); +}); diff --git a/node_server/dev_api/common/instrument/validate-card-data.js b/node_server/dev_api/common/instrument/validate-card-data.js new file mode 100644 index 0000000..1e67419 --- /dev/null +++ b/node_server/dev_api/common/instrument/validate-card-data.js @@ -0,0 +1,31 @@ +module.exports = { + validateCardData +}; + +const formatting = require('../../../utils/formatting'); +const {INVALID_EXPIRY_DATE, INVALID_START_DATE} = require('../errorDicts/instruments.json'); +const HttpError = require('../HttpError'); + +/** + * Validates expiry and issue date of a card + * + * @param {Object} data - instrument data + * @throws {HttpError} + */ +function validateCardData(data) { + const splitExpiryDate = formatting.splitCardDate(data.card.expiryDate); + const now = new Date(); + const expiryDate = new Date(splitExpiryDate.year, splitExpiryDate.month - 1); + + if (expiryDate < now) { + throw new HttpError(INVALID_EXPIRY_DATE.httpCode, INVALID_EXPIRY_DATE.internal, INVALID_EXPIRY_DATE.external); + } + + if (data.card.startDate) { + const splitIssueDate = formatting.splitCardDate(data.card.startDate); + const issueDate = new Date(splitIssueDate.year, splitIssueDate.month - 1); + if (issueDate > now) { + throw new HttpError(INVALID_START_DATE.httpCode, INVALID_START_DATE.internal, INVALID_START_DATE.external); + } + } +} diff --git a/node_server/dev_api/common/instrument/validate-card-data.spec.js b/node_server/dev_api/common/instrument/validate-card-data.spec.js new file mode 100644 index 0000000..d30cd42 --- /dev/null +++ b/node_server/dev_api/common/instrument/validate-card-data.spec.js @@ -0,0 +1,65 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable mocha/no-hooks-for-single-case */ + +'use strict'; +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); + +const expect = chai.expect; +chai.use(sinonChai); +chai.use(chaiAsPromised); + +// eslint-disable-next-line import/no-unassigned-import +require('../../../tools/test/testGlobals'); + +const validateCardData = require('./validate-card-data'); + +const VALID_BODY = { + card: { + startDate: '01-00', + expiryDate: '01-99' + } +}; +const INVALID_EXPIRY_BODY = { + card: { + startDate: '01-00', + expiryDate: '01-00' + } +}; +const INVALID_ISSUE_BODY = { + card: { + startDate: '01-99', + expiryDate: '01-99' + } +}; + +describe('instruments.cards.create', () => { + let clock; + before(() => { + const now = new Date(2020, 1); + clock = sinon.useFakeTimers(now.getTime()); + }); + after(() => { + clock.restore(); + }); + describe('valid Data', () => { + it('does not throw', () => { + return expect(() => validateCardData.validateCardData(VALID_BODY)) + .to.not.throw; + }); + }); + describe('invalid expiry Date', () => { + it('throws HttpError', () => { + return expect(() => validateCardData.validateCardData(INVALID_EXPIRY_BODY)) + .to.throw('BRIDGE: INVALID EXPIRY DATE'); + }); + }); + describe('invalid issue date', () => { + it('throws HttpError', () => { + return expect(() => validateCardData.validateCardData(INVALID_ISSUE_BODY)) + .to.throw('BRIDGE: INVALID START DATE'); + }); + }); +}); diff --git a/node_server/dev_api/config/swagger.json b/node_server/dev_api/config/swagger.json new file mode 100644 index 0000000..240ddaa --- /dev/null +++ b/node_server/dev_api/config/swagger.json @@ -0,0 +1,947 @@ +{ + "swagger": "2.0", + "info": { + "version": "0.1", + "title": "Comcarde Bridge Payment Development API Definition", + "description": "The REST Payment Development API that provides access to specified payment commands. Please contact Comcard for more details and access to the system." + }, + "basePath": "/dev/v0", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "security": [ + { + "bearer": [] + } + ], + "tags": [ + { + "name": "general", + "description": "Functions in the API" + }, + { + "name": "payment", + "description": "Functions related to taking payment in various manners" + } + ], + "securityDefinitions": { + "bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "Bearer token for the specific integration partner. The bearer token **MUST** be kept secure as it provides access to the controlled functionality. The token should be sent in the `\"Authorization\"` header as `\"Bearer \"` following [Section 2.1 of RFC 6750](https://tools.ietf.org/html/rfc6750#section-2.1). Contact Comcarde to request a token for use with this API." + } + }, + "parameters": { + "instrumentID": { + "name": "instrumentID", + "in": "path", + "required": true, + "description": "Unique identifier for payments instrument", + "type": "string", + "pattern": "[0-9a-f]{24}", + "maxLength": 24, + "minLength": 24 + } + }, + "responses": { + "AddedPaymentCard": { + "description": "Success. The card has been stored.", + "schema": { + "$ref": "#/definitions/AddedCardInfo" + } + }, + "badParameterError": { + "description": "Parameter validation failed", + "schema": { + "$ref": "#/definitions/badParametersInfo" + } + }, + "GeneralError": { + "description": "General error response format", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + } + }, + "paths": { + "/test": { + "x-swagger-router-controller": "test_controller", + "get": { + "summary": "Test function", + "description": "Tests that communication with the API works, and the supplied bearer token is valid", + "tags": [ + "general" + ], + "operationId": "test", + "responses": { + "default": { + "$ref": "#/responses/GeneralError" + }, + "200": { + "description": "Successful request: bearer token is valid", + "schema": {} + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + } + } + } + }, + "/payments/worldpay": { + "x-swagger-router-controller": "worldpay_transaction_controller", + "post": { + "summary": "Make a worldpay payment.", + "description": "Create a WorldPay transaction by making a card payment for an amount in pennies.", + "tags": [ "payment" ], + "operationId": "worldpayPayment", + "parameters": [ + { + "name": "body", + "in":"body", + "description": "Create WorldPay transaction body", + "required": true, + "schema": { "$ref": "#/definitions/worldpay-params" } + } + ], + "responses": { + "200": { + "description": "Success. The payment has been made.", + "schema": { + "$ref": "#/definitions/TransactionSucceededInfo" + } + }, + "400": { + "$ref": "#/responses/badParameterError" + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + }, + "default": { + "$ref": "#/responses/GeneralError" + } + } + } + }, + "/payment-instruments/worldpay-merchants": { + "x-swagger-router-controller": "payment_instruments_controller", + "post": { + "summary": "Save a worldpay receiving account.", + "description": "Save the encrypted details of a worldpay receiving account.", + "tags": [ "instruments" ], + "operationId": "saveWorldpayReceivingAccount", + "parameters": [ + { + "name": "body", + "in":"body", + "description": "Save receiving account details.", + "required": true, + "schema": { "$ref": "#/definitions/worldpay-receiving-account-params" } + } + ], + "responses": { + "201": { + "description": "Success. The account has been stored.", + "schema": { + "$ref": "#/definitions/AddedReceiveAccountInfo" + } + }, + "400": { + "$ref": "#/responses/badParameterError" + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + }, + "default": { + "$ref": "#/responses/GeneralError" + } + } + } + }, + "/payment-instruments/cards": { + "x-swagger-router-controller": "payment_instruments_controller", + "post": { + "summary": "Save card details.", + "description": "Save the details of a banking card for future use in payments.", + "tags": [ "instruments" ], + "operationId": "saveCardDetails", + "parameters": [ + { + "name": "body", + "in":"body", + "description": "Save card details.", + "required": true, + "schema": { "$ref": "#/definitions/payment-instrument-no-cv2-params" } + } + ], + "responses": { + "201": { + "description": "Success. The card has been stored.", + "schema": { + "$ref": "#/definitions/AddedCardInfo" + } + }, + "400": { + "$ref": "#/responses/badParameterError" + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + }, + "default": { + "$ref": "#/responses/GeneralError" + } + } + }, + "get": { + "summary": "List card payment instruments", + "description": "Return a list of the card payment instrument IDs of the user.", + "tags": [ "instruments" ], + "operationId": "listCards", + "responses": { + "200": { + "description": "Successful request: users card IDs returned", + "schema": { + "$ref": "#/definitions/paymentInstrumentList" + } + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + }, + "default": { + "$ref": "#/responses/GeneralError" + } + } + } + }, + "/payment-instruments/cards/{instrumentID}/payments": { + "x-swagger-router-controller": "payment_instruments_controller", + "post": { + "summary": "Pay using stored card.", + "description": "Make payment with stored card using Worldpay.", + "tags": [ "instruments" ], + "operationId": "makeWorldpayPaymentWithSavedCard", + "parameters": [ + { "$ref": "#/parameters/instrumentID" }, + { + "name": "body", + "in":"body", + "description": "Make payment with stored card using Worldpay.", + "required": true, + "schema": { "$ref": "#/definitions/transaction-details-stored-card" } + } + ], + "responses": { + "200": { + "description": "Successfully made payment with stored card using Worldpay.", + "schema": { + "$ref": "#/definitions/TransactionSucceededInfo" + } + }, + "400": { + "$ref": "#/responses/badParameterError" + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + }, + "default": { + "$ref": "#/responses/GeneralError" + } + } + } + }, + "/payment-instruments/worldpay-merchants/{instrumentID}/payments": { + "x-swagger-router-controller": "payment_instruments_controller", + "post": { + "summary": "Pay to a stored merchant.", + "description": "Make payment to a stored Worldpay merchant using a specified card.", + "tags": [ "instruments" ], + "operationId": "makeWorldpayPaymentToSavedMerchant", + "parameters": [ + { "$ref": "#/parameters/instrumentID" }, + { + "name": "body", + "in":"body", + "description": "Make payment to stored Worldpay merchant from a card.", + "required": true, + "schema": { "$ref": "#/definitions/transaction-details-stored-merchant" } + } + ], + "responses": { + "200": { + "description": "Successfully made payment to stored Worldpay merchant.", + "schema": { + "$ref": "#/definitions/TransactionSucceededInfo" + } + + }, + "400": { + "$ref": "#/responses/badParameterError" + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + }, + "default": { + "$ref": "#/responses/GeneralError" + } + } + } + }, + "/paycodes": { + "x-swagger-router-controller": "payment_instruments_controller", + "post": { + "summary": "Create a paycode.", + "description": "Create a paycode that can be used later to make a payment.", + "tags": [ "instruments" ], + "operationId": "createPaycode", + "parameters": [ + { + "name": "body", + "in":"body", + "description": "Create a paycode.", + "required": true, + "schema": { "$ref": "#/definitions/create-paycode-details" } + } + ], + "responses": { + "201": { + "description": "Successfully created a paycode.", + "schema": { + "$ref": "#/definitions/CreatePaycodeSucceededInfo" + } + + }, + "400": { + "$ref": "#/responses/badParameterError" + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + }, + "default": { + "$ref": "#/responses/GeneralError" + } + } + } + } + }, + "definitions": { + "worldpay-params": { + "type": "object", + "properties": { + "paymentInstrument": { "$ref": "#/definitions/payment-instrument-params" }, + "payee": { "$ref": "#/definitions/payee-params" }, + "amount": { "$ref": "#/definitions/amount-params" }, + "transactionDetails": { "$ref": "#/definitions/transaction-details-params" } + }, + "required": [ "paymentInstrument", "payee", "amount", "transactionDetails" ] + }, + "payment-instrument-params": { + "type": "object", + "properties": { + "payer": { "$ref": "#/definitions/payer" }, + "card": { "$ref": "#/definitions/card-details-params" } + }, + "required": [ "payer", "card" ] + }, + "payee-params": { + "type": "object", + "properties": { + "worldpay": { + "type": "object", + "properties": { + "receivingAccountServiceKey": { + "$ref": "#/definitions/worldpay-service-key" + } + }, + "required": [ "receivingAccountServiceKey" ] + } + }, + "required": [ "worldpay" ] + }, + "amount-params": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/total-amount" + } + }, + "required": [ "value" ] + }, + "transaction-details-params": { + "type": "object", + "properties": { + "worldpay": { + "type": "object", + "properties": { + "orderDescription": {"$ref": "#/definitions/order-description"} + }, + "required": [ "orderDescription" ] + } + }, + "required": [ "worldpay" ] + }, + "transaction-details-stored-card": { + "type": "object", + "properties": { + "paymentInstrument": {"$ref": "#/definitions/payment-instrument-data"}, + "payee": {"$ref": "#/definitions/payee-params"}, + "amount": {"$ref": "#/definitions/amount-params"}, + "transactionDetails": {"$ref": "#/definitions/transaction-details-params"} + }, + "required": [ "paymentInstrument", "payee", "amount", "transactionDetails" ] + }, + "transaction-details-stored-merchant": { + "type": "object", + "properties": { + "paymentInstrument": {"$ref": "#/definitions/payment-instrument-params"}, + "receiveInstrument": {"$ref": "#/definitions/instrument-ref-data"}, + "amount": {"$ref": "#/definitions/amount-params"}, + "transactionDetails": {"$ref": "#/definitions/transaction-details-params"} + }, + "required": [ "paymentInstrument", "receiveInstrument", "amount", "transactionDetails" ] + }, + "create-paycode-details": { + "type": "object", + "properties": { + "ID": {"$ref": "#/definitions/instrument-id"}, + "key": {"$ref": "#/definitions/instrument-decrypt-key"} + }, + "required": [ "ID", "key" ] + }, + "address": { + "description": "Postal address of payment party.", + "type": "object", + "properties": { + "address1": { "$ref": "#/definitions/address-line1" }, + "address2": { "$ref": "#/definitions/address-line2" }, + "address3": { "$ref": "#/definitions/address-line3" }, + "town": { "$ref": "#/definitions/town" }, + "county": { "$ref": "#/definitions/county" }, + "postcode": { "$ref": "#/definitions/postcode" }, + "phoneNumber": { "$ref": "#/definitions/phone-number" } + }, + "required": [ + "address1", "town", "postcode" + ] + }, + "payment-instrument-data": { + "description": "Data require to use the specified payment instrument", + "type": "object", + "properties": { + "encryptionKey": {"$ref": "#/definitions/card-decrypt-key"}, + "CV2": {"$ref": "#/definitions/card-CV2"} + }, + "required": [ "encryptionKey" ] + }, + "instrument-ref-data": { + "description": "Data required to use the specified payment instrument", + "type": "object", + "properties": { + "encryptionKey": { "$ref": "#/definitions/instrument-decrypt-key" } + }, + "required": [ "encryptionKey" ] + }, + "payer": { + "description": "Details of the paying party.", + "type": "object", + "properties": { + "email": { + "allOf": [ + {"$ref": "#/definitions/email"}, + { "description": "Email of party making payment"} + ] + }, + "firstName": { + "allOf": [ + {"$ref": "#/definitions/name-field"}, + {"example": "John"} + ] + }, + "lastName": { + "allOf": [ + {"$ref": "#/definitions/name-field"}, + {"example": "Doe"} + ] + } + }, + "required": [ + "email", "firstName", "lastName" + ] + }, + "payment-instrument-no-cv2-params": { + "type": "object", + "properties": { + "payer": { "$ref": "#/definitions/payer" }, + "description": { "$ref": "#/definitions/instrument-description" }, + "card": { "$ref": "#/definitions/card-details-params-no-cv2" } + }, + "required": [ "payer", "card" ] + }, + "worldpay-receiving-account-params": { + "type": "object", + "properties": { + "description": { "$ref": "#/definitions/instrument-description" }, + "receivingAccountServiceKey": { "$ref": "#/definitions/worldpay-service-key" } + }, + "required": [ "receivingAccountServiceKey" ] + }, + "card-details-params-no-cv2": { + "description": "Details of the paying card.", + "type": "object", + "properties": { + "nameOnCard": { + "$ref": "#/definitions/full-name-field" + }, + "PAN": { + "$ref": "#/definitions/card-PAN" + }, + "expiryDate": { + "allOf": [ + {"$ref": "#/definitions/card-date"}, + {"description": "Expiry date (MM-YY) of card making payment"} + ] + }, + "startDate": { + "allOf": [ + {"$ref": "#/definitions/card-date"}, + {"description": "Start date (MM-YY) of card making payment"} + ] + }, + "issueNumber": { + "$ref": "#/definitions/issue-number" + }, + "address": { + "$ref": "#/definitions/address" + } + }, + "required": [ + "nameOnCard", "PAN", "expiryDate", "address" + ] + }, + "card-details-params": { + "description": "Details of the paying card.", + "type": "object", + "properties": { + "nameOnCard": { + "$ref": "#/definitions/full-name-field" + }, + "PAN": { + "$ref": "#/definitions/card-PAN" + }, + "expiryDate": { + "allOf": [ + {"$ref": "#/definitions/card-date"}, + {"description": "Expiry date (MM-YY) of card making payment"} + ] + }, + "startDate": { + "allOf": [ + {"$ref": "#/definitions/card-date"}, + {"description": "Start date (MM-YY) of card making payment"} + ] + }, + "issueNumber": { + "$ref": "#/definitions/issue-number" + }, + "CV2": { + "$ref": "#/definitions/card-CV2" + }, + "address": { + "$ref": "#/definitions/address" + } + }, + "required": [ + "nameOnCard", "PAN", "expiryDate", "address" + ] + }, + "order-description": { + "allOf": [ + {"$ref": "#/definitions/general-text"}, + { + "description": "Order description", + "minLength": 1, + "example": "2 Calling Birds, 1 Partridge in a Pear tree" + } + ] + }, + "address-line1": { + "allOf": [ + {"$ref": "#/definitions/address-line"}, + {"description": "First line of address."}, + {"example": "Flat 20"}, + {"minLength": 1} + ] + }, + "address-line2": { + "allOf": [ + {"$ref": "#/definitions/address-line"}, + {"description": "Second line of address."}, + {"example": "Victoria House"} + ] + }, + "address-line3": { + "allOf": [ + {"$ref": "#/definitions/address-line"}, + {"description": "Third line of address"}, + {"example": "15 The Street"} + ] + }, + "town": { + "allOf": [ + {"$ref": "#/definitions/area-description"}, + {"description": "Town name address."}, + {"example": "Christchurch"}, + {"minLength": 1} + ] + }, + "county": { + "allOf": [ + {"$ref": "#/definitions/area-description"}, + {"description": "County name of address."}, + {"example": "Dorset"}, + {"minLength": 1} + ] + }, + "email": { + "example": "a@b.com", + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "x-invalid-pattern": "[^a-zA-Z0-9.%+-.@]", + "minLength": 7, + "maxLength": 255 + }, + "name-field": { + "description": "Single part of a personal name (no spaces).", + "type": "string", + "pattern": "^([A-Za-z]*)$", + "x-invalid-pattern": "[^A-Za-z]", + "minLength": 2, + "maxLength": 255, + "example": "John" + }, + "full-name-field": { + "description": "All parts of a personal name separated by spaces.", + "type": "string", + "pattern": "^([A-Za-z ]*)$", + "x-invalid-pattern": "[^A-Za-z]", + "minLength": 2, + "maxLength": 255, + "example": "John E Doe" + }, + "card-id": { + "description": "Card Unique Identifier", + "type": "string", + "example": "000000000000000000000000", + "pattern": "[0-9a-f]{24}", + "maxLength": 24, + "minLength": 24 + }, + "instrument-id": { + "description": "Instrument Unique Identifier", + "type": "string", + "example": "000000000000000000000000", + "pattern": "[0-9a-f]{24}", + "maxLength": 24, + "minLength": 24 + }, + "card-decrypt-key": { + "allOf": [ + {"$ref": "#/definitions/uuid"}, + {"description": "Decryption key required to use card."} + ] + }, + "instrument-decrypt-key": { + "allOf": [ + {"$ref": "#/definitions/uuid"}, + {"description": "Decryption key required to use instrument."} + ] + }, + "transaction-id": { + "allOf": [ + {"$ref": "#/definitions/uuid"}, + {"description": "ID of transaction."} + ] + }, + "card-PAN": { + "description": "PAN (long number) of card.", + "type": "string", + "pattern": "^[0-9][0-9 ]*[0-9]+$", + "minLength": 8, + "maxLength": 255, + "example": "4444 3333 2222 1111" + }, + "obfuscated-card-pan": { + "description": "Obfuscated PAN (long number) of card.", + "type": "string", + "pattern": "^[0-9][0-9* ]*[0-9]+$", + "minLength": 8, + "maxLength": 255, + "example": "4*** **** **** *111" + }, + "total-amount": { + "description": "Total amount in pence (100 = £1.00)", + "type": "integer" + }, + "card-date": { + "example": "01-00", + "description": "Date (MM-YY)", + "type": "string", + "pattern": "^(?:0[1-9]|1[0-2])-[0-9][0-9]$", + "x-invalid-pattern": "[^0-9\\-]" + }, + "postcode": { + "description": "Postal code for address.", + "type": "string", + "pattern": "^([A-Za-z0-9\\- ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9\\- ]", + "example": "BH23 6AA", + "minLength": 4, + "maxLength": 15 + }, + "issue-number": { + "description": "Issue number on the bank card. Only applies to some cards", + "type": "integer", + "minimum": 0, + "maximum": 9999999, + "example": 1 + }, + "card-CV2": { + "example":"000", + "description": "CVV of bank card", + "type": "string", + "pattern": "^[0-9]*$", + "minLength": 3, + "maxLength": 255 + }, + "worldpay-service-key": { + "description": "The Worldpay Service Key format.", + "type": "string", + "pattern": "^(?:T_S_|T_C_|L_S_|L_C_)[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "example": "T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57", + "x-invalid-pattern": "[^0-9a-f\\-_TLSC]" + }, + "phone-number": { + "description": "Phone number.", + "type": "string", + "pattern": "^[+]?[0-9 ]+[0-9]$", + "minLength": 5, + "maxLength": 255, + "example": "+44 123 1110000" + }, + "area-description": { + "allOf": [ + {"$ref": "#/definitions/general-text"}, + {"description": "General area format: town, county and so on."}, + {"example": "Dorset"}, + {"maxLength": 255} + ] + }, + "address-line": { + "allOf": [ + {"$ref": "#/definitions/general-text"}, + {"description": "General address line."}, + {"example": "Dorset"}, + {"maxLength": 255}, + {"example": "1 Second Street"} + ] + }, + "instrument-description": { + "allOf": [ + {"$ref": "#/definitions/general-text"}, + {"description": "Description of the payment instrument."}, + {"example": "BloggsCo Inc. account."}, + {"minLength": 1}, + {"maxLength": 255} + ] + }, + "payment-instrument-list-item": { + "type": "object", + "properties": { + "cardID": { "$ref": "#/definitions/card-id" }, + "description": {"$ref": "#/definitions/instrument-description"}, + "obfuscatedCardPAN": {"$ref": "#/definitions/obfuscated-card-pan"} + }, + "required": ["cardID"] + }, + "paycodeString": { + "description": "Paycode string. Mostly 0-9A-Z with some ambiguous letters removed", + "type": "string", + "minLength": 5, + "maxLength": 10, + "pattern": "^([0-9ABCDEFGHJKLMNPRSTUVWXYZ]*)$", + "x-invalid-pattern": "[^0-9ABCDEFGHJKLMNPRSTUVWXYZ]", + "example": "ABC12" + }, + "general-text": { + "description": "General text with spaces + special chars", + "type": "string", + "pattern": "^([A-Za-z 0-9'[\\]()@?!\\-/.,_&*:;+=]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9'[\\]()@?!\\-/.,_&*:;+=]", + "maxLength": 255, + "example": "Some Text With Spaces And With'&','*',etc." + }, + "uuid": { + "description": "Unique identifier", + "type": "string", + "example": "00000000-0000-0000-0000-000000000000", + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + "maxLength": 36, + "minLength": 36 + }, + "sha256": { + "description": "A SHA-256 value.", + "allOf": [ + { + "$ref": "#/definitions/lowerCaseHex" + }, + { + "example": "f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1", + "minLength": 64, + "maxLength": 64 + } + ] + }, + "lowerCaseHex": { + "description": "Lower case, hexadecimal string (for hashes etc.)", + "type": "string", + "pattern": "^([a-f0-9]*)$", + "x-invalid-pattern": "[^a-f0-9]" + }, + "paymentInstrumentList": { + "type": "object", + "description": "Successful listing", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/payment-instrument-list-item" + } + } + }, + "required": ["data"] + }, + "AddedCardInfo": { + "description": "Reference information to use stored card", + "type": "object", + "properties": { + "cardID": {"$ref": "#/definitions/card-id"}, + "cardUsageKey": {"$ref": "#/definitions/card-decrypt-key"} + }, + "required": ["cardID", "cardUsageKey"] + }, + "AddedReceiveAccountInfo": { + "description": "Reference information to use stored instrument", + "type": "object", + "properties": { + "ID": {"$ref": "#/definitions/instrument-id"}, + "key": {"$ref": "#/definitions/instrument-decrypt-key"} + }, + "required": ["ID", "key"] + }, + "TransactionSucceededInfo": { + "description": "A payment has been successfully made", + "type": "object", + "properties": { + "transaction": { + "description": "Contains the transaction ID", + "type": "object", + "properties": { + "id": { + "allOf": [ + {"$ref": "#/definitions/uuid"}, + { "description": "Transaction Unique Identifier"} + ] + } + }, + "required": [ + "id" + ] + } + }, + "required": ["transaction"] + }, + "CreatePaycodeSucceededInfo": { + "description": "Paycode created successfully.", + "type": "object", + "properties": { + "paycode": {"$ref": "#/definitions/paycodeString"} + }, + "required": ["paycode"] + }, + "badParametersInfo": { + "description": "Validation failed", + "type": "object", + "properties": { + "code": { + "description": "Error code", + "type": "integer", + "example": -1 + }, + "info": { + "description": "Text description of the issue", + "type": "string", + "example": "Unknown Error" + }, + "response": { + "description": "Optional additional information", + "type": "object" + } + }, + "required": ["code", "info"], + "example": { + "code": 1, + "info": "Some error" + } + }, + "ErrorInfo": { + "description": "More information on the error reason", + "type": "object", + "properties": { + "code": { + "description": "Error code", + "type": "integer", + "example": -1 + }, + "info": { + "description": "Text description of the issue", + "type": "string", + "example": "Unknown Error" + } + }, + "required": ["code", "info"], + "example": { + "code": 1, + "info": "Some error" + } + } + } +} diff --git a/node_server/dev_api/controllers/acquirers/common/AcquirerError.js b/node_server/dev_api/controllers/acquirers/common/AcquirerError.js new file mode 100644 index 0000000..3164651 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/common/AcquirerError.js @@ -0,0 +1,21 @@ +// extends the HttpError object + +const HttpError = require('../../../common/HttpError'); + +module.exports = class AcquirerError extends HttpError { + /** + * Creates a customised Error Object with inheritance + * + * @param {Object} error as mapped by the caller + * @param {?Object} info will be attached to the object + */ + constructor(error, info) { + // eslint-disable-next-line lodash/prefer-lodash-typecheck + if (typeof error !== 'object') { + throw new TypeError('First argument must be an object'); + } + super(error.httpCode, error.internal, error.external); + this.acquirerError = error; + this.acquirerInfo = info; + } +}; diff --git a/node_server/dev_api/controllers/acquirers/common/AcquirerError.spec.js b/node_server/dev_api/controllers/acquirers/common/AcquirerError.spec.js new file mode 100644 index 0000000..643dce8 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/common/AcquirerError.spec.js @@ -0,0 +1,28 @@ +'use strict'; + +const errorDict = require('./errorDicts/acquirer.json'); +const AcquirerError = require('./AcquirerError'); +const chai = require('chai'); +const HttpError = require('../../../common/HttpError'); + +const expect = chai.expect; + +const validMapping = errorDict[Object.keys(errorDict)[1]]; + +describe('AcquirerError', () => { + it('First argument must be an object', () => { + expect(() => new AcquirerError(1)).to.throw(); + }); + it('is an instance of HttpError', () => { + expect(new AcquirerError(validMapping)).to.be.instanceof(HttpError); + }); + it('Saves mapping & info onto the instance', () => { + const error = new AcquirerError(validMapping, 244); + expect(error.acquirerInfo).to.equal(244); + expect(error.acquirerError).to.equal(validMapping); + }); + it('passes error mapping internal respresentation to the Error instance', () => { + const error = new AcquirerError(validMapping); + expect(error.toString().includes(validMapping.internal)).to.equal(true); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/common/errorDicts/acquirer.json b/node_server/dev_api/controllers/acquirers/common/errorDicts/acquirer.json new file mode 100644 index 0000000..b26216f --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/common/errorDicts/acquirer.json @@ -0,0 +1,130 @@ +{ + "UNKNOWN_ACQUIRER": { + "internal": "BRIDGE: UNKNOWN ACQUIRER", + "httpCode": 400, + "external": { + "description": "Merchant acquirer unknown", + "code": 532 + } + }, + "INVALID_COMBINATION": { + "internal": "BRIDGE: CANT USE PAYMENT METHOD WITH ACQUIRER", + "httpCode": 400, + "external": { + "description": "Invalid payment type", + "code": 536 + } + }, + "ACQUIRER_DOWN": { + "internal": "BRIDGE: CANT COMMUNICATE WITH ACQUIRER", + "httpCode": 502, + "external": { + "description": "Cannot connect to acquirer", + "code": 533 + } + }, + "INVALID_MERCHANT_NAME": { + "internal": "BRIDGE: MERCHANT NAME TOO SHORT", + "httpCode": 403, + "external": { + "description": "Cannot connect to acquirer", + "code": 534 + } + }, + "INVALID_MERCHANT_ACCOUNT_DETAILS": { + "internal": "BRIDGE: MERCHANT ACCOUNT DETAILS MISSING OR CORRUPT", + "httpCode": 500, + "external": { + "description": "Receiving account information unreadable", + "code": 535 + } + }, + "INVALID_CARD_DETAILS": { + "internal": "BRIDGE: CARD DETAILS MISSING OR CORRUPTED", + "httpCode": 500, + "external": { + "description": "Receiving account information unreadable", + "code": 536 + } + }, + "ACQUIRER_UNKNOWN_ERROR": { + "internal": "BRIDGE: UNKNOWN ACQUIRER ERROR", + "httpCode": 500, + "external": { + "description": "Error processing payment", + "code": 537 + } + }, + "ACQUIRER_BAD_REQUEST": { + "internal": "BRIDGE: ACQUIRER: BAD REQUEST", + "httpCode": 400, + "external": { + "description": "Error processing payment", + "code": 538 + } + }, + "ACQUIRER_INVALID_PAYMENT_DETAILS": { + "internal": "BRIDGE: ACQUIRER: UNSUPPORTED OR INVALID PAYMENT DETAILS", + "httpCode": 400, + "external": { + "description": "Invalid paymernt details", + "code": 540 + } + }, + "ACQUIRER_TKN_EXPIRED": { + "internal": "BRIDGE: ACQUIRER: TOKEN EXPIRED", + "httpCode": 400, + "external": { + "description": "Invalid token", + "code": 500 + } + }, + "ACQUIRER_UNAUTHORIZED": { + "internal": "BRIDGE: ACQUIRER: UNAUTHORIZED", + "httpCode": 400, + "external": { + "description": "Merhcant account unauthorized with acquirer", + "code": 541 + } + }, + "ACQUIRER_MERCHANT_DISABLED": { + "internal": "BRIDGE: ACQUIRER: DISABLED", + "httpCode": 400, + "external": { + "description": "Merchant account disabled with acquirer", + "code": 542 + } + }, + "ACQUIRER_TOKEN_NOT_FOUND": { + "internal": "BRIDGE: ACQUIRER: TOKEN NOT FOUND", + "httpCode": 500, + "external": { + "description": "Internal server error", + "code": 500 + } + }, + "ACQUIRER_INTERNAL_SERVER_ERROR": { + "internal": "BRIDGE: ACQUIRER: INTERNAL SERVER ERROR AT ACQUIRER", + "httpCode": 502, + "external": { + "description": "Error processing payment", + "code": 543 + } + }, + "CARD_EXPIRED": { + "internal": "BRIDGE: CARD HAS EXPIRED", + "httpCode": 403, + "external": { + "description": "Card has expired", + "code": 544 + } + }, + "PAYMENT_FAILED_UNSPECIFIED": { + "internal": "BRIDGE: UNSPECIFIED PAYMENT FAILURE", + "httpCode": 400, + "external": { + "description": "Unspecified error", + "code": 545 + } + } +} diff --git a/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.js b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.js new file mode 100644 index 0000000..2bac6a1 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.js @@ -0,0 +1,42 @@ +/* eslint-disable max-nested-callbacks */ +'use strict'; + +const encrypt = require('./create/encrypt'); +const daoAccount = require('../../../../common/daoFactory')('collectionPaymentInstrument'); +const dataMapper = require('./create/data-mapper'); +const uuidv4 = require('uuid/v4'); + +/** + * 1. mapping + * 2. encrypt the service key + * 2. creating the Worldpay online merchant object + * + * @param {Object} body - contains instrument information + * @param {string} userId - uuid used to tie the instrument to the user + * @returns {Promise} + * @throws {HttpError} + */ +function create(body, userId) { + return Promise.resolve() + .then(() => { + // Maps the data + const mappedData = dataMapper.dataMapper(body, userId); + + const key = uuidv4(); + + // Encrypts sensitive data, returning everything including newly encrypted data minus the unencrypted data + return encrypt.encrypt(mappedData, key, userId) + + // Adds the object to database + .then((encryptedData) => { + return daoAccount.createOne(encryptedData).then((ID) => { + return { + key, + ID + }; + }); + }); + }); +} + +module.exports = {create}; diff --git a/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.spec.js new file mode 100644 index 0000000..c6afbc8 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.spec.js @@ -0,0 +1,101 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable mocha/no-hooks-for-single-case */ + +'use strict'; +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +// eslint-disable-next-line import/no-unassigned-import +require('../../../../../tools/test/testGlobals'); + +const create = rewire('./create'); + +const encryptStub = create.__get__('encrypt'); +const daoAccountStub = create.__get__('daoAccount'); +const dataMapperStub = create.__get__('dataMapper'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const INSTRUMENT_UUID = '5a9d370a9d5be1158473caca'; +const USER_ID = 'dc870f553840a414962875b3'; +const UNMAPPED_BODY = { + description: 'Bloggs Co Inc', + receivingAccountServiceKey: 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57' +}; +let MAPPED_INSTRUMENT; + +/** + * Wrapper for mocha's `it` testcase function to wait for the result of the + * function before running the expectations. + * + * @param {Object} body - a parmeter of the function + * @param {string} description - The description for the test + * @param {Function} expectation - The expectation fucntion for this test + * + * @returns {Promise} - Promise for the completion of the test + */ +function itP(body, description, expectation) { + it(description, () => { + return create.create(body, USER_ID) + .then((accountID) => { + return expectation(accountID); + }); + }); +} + +describe('aquirers.worldpay.create-merchant.create', () => { + beforeEach(() => { + MAPPED_INSTRUMENT = { + UserID: USER_ID, + IconLocation: 'worldpay-account.png', + PaymentsAccount: 0, + ReceivingAccount: 1, + VendorAccountName: 'Worldpay Online Payments', + VenderID: 'Worldpay', + AccountType: 'Worldpay Online Payments Account', + LastUpdate: new Date(), + WorldpayOnlinePaymentsInfo: { + WorldpayServiceKeyToBeEncrypted: 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57', + ServiceKey: 'T_S_********-****-****-****-********fc57' + }, + Description: 'buisiness account' + }; + + sandbox.stub(daoAccountStub, 'createOne').resolves(INSTRUMENT_UUID); + sandbox.spy(encryptStub, 'encrypt'); + sandbox.stub(dataMapperStub, 'dataMapper').returns(MAPPED_INSTRUMENT); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('creates an instrument', () => { + itP(UNMAPPED_BODY, 'it maps the data', () => { + return expect(dataMapperStub.dataMapper).to.have.been + .calledOnce + .calledWith(UNMAPPED_BODY, USER_ID); + }); + itP(UNMAPPED_BODY, 'it encrypts the data', () => { + return expect(encryptStub.encrypt).to.have.been + .calledOnce + .calledWith( + MAPPED_INSTRUMENT, + sinon.match.string, + USER_ID); + }); + itP(UNMAPPED_BODY, 'it creates the instrument object', () => { + return expect(daoAccountStub.createOne).to.have.been + .calledOnce; + }); + itP(UNMAPPED_BODY, 'it returns the instrument ID', (ID) => { + return expect(ID).to.include({ + ID: INSTRUMENT_UUID}); + }); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.js b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.js new file mode 100644 index 0000000..5b78fc3 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.js @@ -0,0 +1,37 @@ +'use strict'; + +// Place holder code so that unit tests pass + +module.exports = { + dataMapper +}; + +const mainDB = require('../../../../../../ComServe/mainDB.js'); +const anon = require('../../../../../../utils/anon.js'); + +/** + * Maps data into payment instrument contract + * + * @param {Object} data + * @param {Object} userID + * @returns {Object} + */ +function dataMapper(data, userID) { + const updateTime = new Date(); + const output = mainDB.blankWorldpayOnlinePayments(); + + output.UserID = userID; + + output.WorldpayOnlinePaymentsInfo.WorldpayServiceKeyToBeEncrypted = data.receivingAccountServiceKey; + output.WorldpayOnlinePaymentsInfo.ServiceKey = anon.anonymiseWorldpayServiceKey(data.receivingAccountServiceKey); + + if (data.description) { + output.Description = data.description; + } + + output.VendorAccountName = 'Worldpay Online Payments'; + output.VendorID = 'Worldpay'; + output.IconLocation = 'worldpay-account.png'; + output.LastUpdate = updateTime; + return output; +} diff --git a/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.spec.js new file mode 100644 index 0000000..8b7ffe3 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.spec.js @@ -0,0 +1,85 @@ +/* eslint-disable max-nested-callbacks */ +'use strict'; + +// eslint-disable-next-line import/no-unassigned-import +require('../../../../../../tools/test/testGlobals'); +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const _ = require('lodash'); + +const dataMapper = require('./data-mapper'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +let clock; + +const USER_ID = 'agfwf232f'; + +const MIN_INPUT_DATA = { + receivingAccountServiceKey: 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57' +}; +const MAX_INPUT_DATA = _.defaultsDeep( + { + description: 'business account' + }, + MIN_INPUT_DATA +); + +let MIN_RETURNED_DATA; +let MAX_RETURNED_DATA; + +describe('acquirer.worldpay.instrument.create', () => { + describe('data mapping', () => { + before(() => { + clock = sinon.useFakeTimers(); + + MIN_RETURNED_DATA = { + APIVersion: '0.0.0.0-unittest', + UserID: USER_ID, + Description: '', + IconLocation: 'worldpay-account.png', + PaymentsAccount: 0, + ReceivingAccount: 1, + VendorAccountName: 'Worldpay Online Payments', + VendorID: 'Worldpay', + AccountType: 'Worldpay Online Payments Account', + LastUpdate: new Date(), + LastVersion: 1, + Integrity: null, + WorldpayOnlinePaymentsInfo: { + WorldpayServiceKeyToBeEncrypted: 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57', + ServiceKey: 'T_S_********-****-****-****-********fc57', + ServiceKeyEncrypted: '' + } + }; + MAX_RETURNED_DATA = _.defaultsDeep( + { + Description: 'business account' + }, + MIN_RETURNED_DATA + ); + }); + + after(() => { + clock.restore(); + sandbox.restore(); + }); + it('returns minimum set of mapped data', () => { + const output = dataMapper.dataMapper(MIN_INPUT_DATA, USER_ID); + expect(output) + .to.deep.equal(MIN_RETURNED_DATA); + } + ); + it('returns maximum set of mapped data', () => { + const output = dataMapper.dataMapper(MAX_INPUT_DATA, USER_ID); + expect(output) + .to.deep.equal(MAX_RETURNED_DATA); + } + ); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.js b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.js new file mode 100644 index 0000000..e654a91 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.js @@ -0,0 +1,63 @@ +module.exports = { + encrypt +}; + +const _ = require('lodash'); + +const utils = require(global.pathPrefix + 'utils.js'); +const hashString = require('../../../../../common/hashString'); + +/** + * This function encrypts the various Worldpay details as required and available. + * + * @param {Object} data - the data containing the details to encrypt + * @param {string} key - the key from the instrument + * @param {string} userID - the _id of the user as a string + * + * @returns {?Object} - an object with the decrypted details + * @throws {TypeError} - a type error + * @throws {Error} - an error + */ +function encrypt(data, key, userID) { + return Promise.resolve() + .then(() => { + return hashString.hashString(key).then((hashedKey) => { + /** + * Encrypt and store the card details. + */ + const encryptedDetails = {}; + + // + // Check if there is anything to encrypt + // + if (!data.WorldpayOnlinePaymentsInfo) { + throw new Error('No data set'); + } + if (_.isUndefined(data.WorldpayOnlinePaymentsInfo.WorldpayServiceKeyToBeEncrypted) || + data.WorldpayOnlinePaymentsInfo.WorldpayServiceKeyToBeEncrypted === '') { + throw new Error('ServiceKeyToBeEncrypted has not been set'); + } + + const encryptTemp = utils.encryptDataV3( + data.WorldpayOnlinePaymentsInfo.WorldpayServiceKeyToBeEncrypted, + hashedKey, + userID); + if (!_.isString(encryptTemp)) { + throw new TypeError('Error when encrypting the service key.'); + } + encryptedDetails.ServiceKeyEncrypted = encryptTemp; + + const removeArray = ['WorldpayServiceKeyToBeEncrypted']; + + const temp = _.omit(data.WorldpayOnlinePaymentsInfo, removeArray); + const encryptedServiceKey = _.defaults( + {}, + encryptedDetails, + temp + ); + data.WorldpayOnlinePaymentsInfo = encryptedServiceKey; + return data; + }); + }); +} + diff --git a/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.spec.js new file mode 100644 index 0000000..3e4c970 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.spec.js @@ -0,0 +1,137 @@ +/* eslint-disable no-empty */ +/* eslint max-nested-callbacks: ["error", 7] */ + +/** + * Unit testing file for encrypt + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../../../../../tools/test/testGlobals.js'); +const _ = require('lodash'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const encrypt = rewire('./encrypt.js'); +const utilsStub = encrypt.__get__('utils'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); + +const ENCRYPTION_KEY = 'go3rn2ofno2'; +const HEX_ENCRYPTION_KEY = '9e43171d13370d1ad081c0725d8221af80fb7be6c4e4c60bb472a62c4d4a7458'; +const USER_ID = 'o5oij5oioj23oij'; + +const FAKE_ENCRYPTED_DETAILS = '4j3nrkj23b4rk'; +const FAKE_ERROR = {error: 'This is an error'}; + +const SERVICE_KEY = 'T_S_1341245sdfvs'; + +const ACCOUNT = { + WorldpayOnlinePaymentsInfo: { + WorldpayServiceKeyToBeEncrypted: SERVICE_KEY, + ServiceKeyEncrypted: '' + } +}; +const DATA = _.defaults( + {moreData: 'someMoreData'}, + ACCOUNT +); +const ENCRYPTED_FULL_ACCOUNT = { + WorldpayOnlinePaymentsInfo: {ServiceKeyEncrypted: FAKE_ENCRYPTED_DETAILS}, + moreData: 'someMoreData' +}; +const INVALID_ACCOUNT = { + WorldpayOnlinePaymentsInfo: { + WorldpayServiceKeyToBeEncrypted: '' + } +}; +const NO_DATA = {}; + +/** + * Wrapper for mocha's `it` testcase function to wait for the result of the + * function before running the expectations. + * + * @param {Object} data - a parmeter of the function + * @param {string} description - The description for the test + * @param {Function} expectation - The expectation fucntion for this test + * + * @returns {Promise} - Promise for the completion of the test + */ +function itP(data, description, expectation) { + it(description, () => { + return encrypt.encrypt(data, ENCRYPTION_KEY, USER_ID) + .then((accountID) => { + return expectation(accountID); + }) + .catch((error) => { + return expectation(error); + }); + }); +} + +describe('aquirers.worldpay.create-merchant.create.encrypt', () => { + /** + * Stub the functions that will be used for the "happy path" + * The responses are specifically overriden below for testing the error cases + */ + beforeEach(() => { + sandbox.spy(encrypt, 'encrypt'); + sandbox.stub(utilsStub, 'encryptDataV3') + .onCall(0).returns(FAKE_ENCRYPTED_DETAILS); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('successfully', () => { + describe('with required Worldpay Account fields set', () => { + itP(_.clone(DATA), 'encrypting the service key', () => { + return expect(utilsStub.encryptDataV3).to.have.been + .calledOnce + .calledWith(SERVICE_KEY, HEX_ENCRYPTION_KEY, USER_ID); + }); + itP(_.clone(DATA), 'returning encrypted details ', (account) => { + return expect(account).to.deep.equal(ENCRYPTED_FULL_ACCOUNT); + }); + }); + }); + describe('with a failure', () => { + describe('to encrypt the data', () => { + beforeEach(() => { + utilsStub.encryptDataV3 + .onCall(0).returns(FAKE_ERROR); + }); + itP(_.clone(ACCOUNT), 'fails to encrypt the sevice key', () => { + return expect(utilsStub.encryptDataV3).to.have.been + .calledOnce + .calledWith(SERVICE_KEY, HEX_ENCRYPTION_KEY, USER_ID); + }); + itP(_.clone(ACCOUNT), 'throwing an error', (error) => { + return expect(error.message).to.equal('Error when encrypting the service key.'); + }); + }); + describe('to send invalid data to encrypt', () => { + itP(INVALID_ACCOUNT, 'does not try to encrypt anything', () => { + return expect(utilsStub.encryptDataV3).to.not.have.been.called; + }); + itP(INVALID_ACCOUNT, 'throwing an error', (error) => { + return expect(error.message).to.equal('ServiceKeyToBeEncrypted has not been set'); + }); + }); + describe('to send no data to encrypt', () => { + itP(NO_DATA, 'does not try to encrypt anything', () => { + return expect(utilsStub.encryptDataV3).to.not.have.been.called; + }); + itP(NO_DATA, 'throwing an error', (error) => { + return expect(error.message).to.equal('No data set'); + }); + }); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.js new file mode 100644 index 0000000..d302dc1 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.js @@ -0,0 +1,52 @@ +'use strict'; + +const errors = require('../../../common/errorDicts/acquirer.json'); +const AcquirerError = require('../../../common/AcquirerError'); + +/** + * Determines a standard key for the error + * + * @param {?Object} error response + * @returns {string} + * @private + */ +const mapKey = (error) => { + if (error) { + if (error.hasOwnProperty('customCode')) { + // Other errors are converted and returned + return { + // Validation errors + UNAUTHORIZED: errors.ACQUIRER_UNAUTHORIZED, + MERCHANT_DISABLED: errors.ACQUIRER_MERCHANT_DISABLED, + + // Other errors + BAD_REQUEST: errors.ACQUIRER_BAD_REQUEST, + TKN_EXPIRED: errors.ACQUIRER_TKN_EXPIRED, + ERROR_PARSING_JSON: errors.ACQUIRER_BAD_REQUEST, + MEDIA_TYPE_NOT_SUPPORTED: errors.ACQUIRER_BAD_REQUEST, + INTERNAL_SERVER_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR, + UNEXPECTED_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR, + API_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR, + INVALID_PAYMENT_DETAILS: errors.ACQUIRER_INVALID_PAYMENT_DETAILS + }[error.customCode] || errors.ACQUIRER_UNKNOWN_ERROR; + } + + // Some network type error, so report it as service being down + return errors.ACQUIRER_DOWN; + } + return errors.ACQUIRER_UNKNOWN_ERROR; +}; + +/** + * Creates a worldpay Error object from the API response + */ +module.exports = class extends AcquirerError { + /** + * @class + * @param {?Object} response the Worldpay error or if empty an unknown (non-success) error + */ + constructor(response) { + super(mapKey(response), response); + this.worldpayResponse = response; + } +}; diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.spec.js new file mode 100644 index 0000000..0cb7ca1 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.spec.js @@ -0,0 +1,43 @@ +'use strict'; + +const chai = require('chai'); +const errors = require('../../../common/errorDicts/acquirer.json'); +const WorldpayPaymentError = require('./Error'); +const AcquirerError = require('../../../common/AcquirerError'); + +const {expect} = chai; + +describe('acquirer.worldpay.errorMap', () => { + it('returns a WorldPayAcquirerError containing key ACQUIRER_DOWN if no .customCode provided', () => { + const error = new WorldpayPaymentError({}); + expect(error).to.be.instanceof(WorldpayPaymentError); + expect(error).to.be.instanceof(AcquirerError); + expect(error.acquirerError).to.equal(errors.ACQUIRER_DOWN); + }); + + it('returns a WorldPayAcquirerError containing key ACQUIRER_UNKNOWN_ERROR if unrecognised', () => { + const error = new WorldpayPaymentError({customCode: 'foo'}); + expect(error).to.be.instanceof(WorldpayPaymentError); + expect(error).to.be.instanceof(AcquirerError); + expect(error.acquirerError).to.equal(errors.ACQUIRER_UNKNOWN_ERROR); + }); + + it('returns a WorldPayAcquirerError containing key ACQUIRER_UNKNOWN_ERROR on non-success', () => { + const error = new WorldpayPaymentError(); + expect(error).to.be.instanceof(WorldpayPaymentError); + expect(error).to.be.instanceof(AcquirerError); + expect(error.acquirerError).to.equal(errors.ACQUIRER_UNKNOWN_ERROR); + }); + + it('returns a WorldPayAcquirerError containing key ACQUIRER_EXISTINGKEY if recognised', () => { + const error = new WorldpayPaymentError({customCode: 'BAD_REQUEST'}); + expect(error).to.be.instanceof(WorldpayPaymentError); + expect(error).to.be.instanceof(AcquirerError); + expect(error.acquirerError).to.equal(errors.ACQUIRER_BAD_REQUEST); + }); + it('Saves the original response', () => { + const obj = {}; + const error = new WorldpayPaymentError(obj); + expect(error.worldpayResponse).to.equal(obj); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.js new file mode 100644 index 0000000..ff567dc --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.js @@ -0,0 +1,128 @@ +/** + * Functions to interact with Worldpay + * This is based on the Worldpay JSON API Specification. + * @see {@url https://developer.worldpay.com/jsonapi/api} + */ +'use strict'; + +const _ = require('lodash'); +const httpServiceCb = require('../../../../../ComServe/worldpay'); +const debug = require('debug')('utils:acquirers:worldpay:payment'); +const WorldpayAcquirerError = require('./errors/Error'); +const formatting = require('../../../../../utils/formatting'); + +/** + * This Promisifies the CommServe file in place while supporting js fetch (can be polyfilled with node-fetch) + * At some point CommServe/worldpay.js should be refactored to allow this fn to disappear + * + * @param {string} uri path to be concatenated onto the acquirer API URL + * @param {string} authKey wrote as a header by the underlying module + * @param {Object} opts fetch() conforming data + * @private + * @returns {Promise} + */ +function fetch(uri, authKey, opts) { + return new Promise((resolve, reject) => { + const cb = (err, outcome) => { + if (err) { + return reject(err); + } + resolve(outcome); + return null; + }; + + // function(method, urlPath, authKey, additionalHeaders, postBody, callback) { + httpServiceCb.worldpayFunction(opts.method, uri, authKey, null, opts.body, cb); + }); +} + +/** + * Makes a manual transaction using raw data + * + * @param {Object} data - payment data + * @returns {Promise} containing mapped success/failure data + */ +function payment(data) { + // Massage data into Worldpay format + const {payer, card} = data.paymentInstrument; + const {address} = card; + const body = { + orderType: 'ECOM', + currencyCode: 'GBP', + settlementCurrency: 'GBP', // In case merchant has enabled multiple currencies + amount: data.amount.value, + orderDescription: data.transactionDetails.worldpay.orderDescription, + name: payer.firstName + ' ' + payer.lastName, + paymentMethod: { + type: 'Card', + name: card.nameOnCard, + cvc: card.CV2, + expiryYear: Number(formatting.splitCardDate(card.expiryDate).year), + expiryMonth: Number(formatting.splitCardDate(card.expiryDate).month), + cardNumber: card.PAN + }, + billingAddress: { + address1: address.address1, + city: address.town, + state: address.county, + countryCode: 'GB' + } + }; + + // optional data + + // email + if (payer.email) { + body.shopperEmailAddress = payer.email; + } + + // card + const {paymentMethod} = body; + if (card.startDate) { + paymentMethod.startYear = Number(formatting.splitCardDate(card.startDate).year); + paymentMethod.startMonth = Number(formatting.splitCardDate(card.startDate).month); + } + if (card.issueNumber) { + paymentMethod.issueNumber = String(card.issueNumber); // api says string + } + + // address + const {billingAddress} = body; + [ + ['postcode', 'postalCode'], + ['phoneNumber', 'telephoneNumber'], + ['address2', 'address2'], + ['address3', 'address3'] + ].forEach((xy) => { + if (address[xy[0]]) { + billingAddress[xy[1]] = address[xy[0]]; + } + }); + + // Make the request + return fetch('orders', data.payee.worldpay.receivingAccountServiceKey, { + method: 'POST', + body + }) + .catch((error) => { + debug('orders error:', error); + throw new WorldpayAcquirerError(error); + }) + .then((response) => { + if (response.paymentStatus !== 'SUCCESS') { + throw new WorldpayAcquirerError(); + } + + return { + transaction: { + id: response.orderCode + }, + additionalInfo: { + cardSchemeName: _.get(response, 'paymentResponse.cardSchemeName', 'unspecified'), + riskScore: _.get(response, 'riskScore.value', 'unspecified') + } + }; + }); +} + +module.exports = {payment}; diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.spec.js new file mode 100644 index 0000000..10fa7fb --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.spec.js @@ -0,0 +1,178 @@ +/* eslint-disable import/no-unassigned-import */ +/* eslint-disable mocha/no-hooks-for-single-case */ +/* eslint-disable global-require */ +/* eslint max-nested-callbacks: ["error", 99] */ + +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +require('../../../../../tools/test/testGlobals'); + +const worldpay = require(global.pathPrefix + './worldpay'); +const {payment} = require('./payment'); +const WorldpayPaymentError = require('./errors/Error'); + +const {expect} = chai; +const lodashMerge = require('lodash').merge; + +chai.use(sinonChai); + +const INPUT_DATA_MANDATORY = { + paymentInstrument: { + card: { + nameOnCard: 'Custard Cream', + expiryDate: '10-22', + CV2: '054', + PAN: '4444 4444 4444 4444', + address: { + town: 'Liverpool', + county: 'Tyne & Wear', + address1: '24 Broken Road' + } + }, + payer: { + firstName: 'Crystal', + lastName: 'Ball' + } + }, + payee: { + worldpay: { + receivingAccountServiceKey: '555666' + } + }, + amount: { + value: 3434 + }, + transactionDetails: { + worldpay: { + orderDescription: 'Playstation' + } + } +}; + +const INPUT_DATA_OPTIONAL = { + paymentInstrument: { + payer: { + email: 'i@ms.com' + }, + card: { + startDate: '10-15', + issueNumber: '555', + address: { + postcode: 'EH7 7HH', + phoneNumber: '1234567890', + address2: 'Foobar Town', + address3: 'Foobar City' + } + } + } +}; + +const OUTPUT_DATA_MANDATORY = { + amount: 3434, + billingAddress: { + address1: '24 Broken Road', + city: 'Liverpool', + state: 'Tyne & Wear', + countryCode: 'GB' + }, + currencyCode: 'GBP', + name: 'Crystal Ball', + orderDescription: 'Playstation', + orderType: 'ECOM', + paymentMethod: { + cardNumber: '4444 4444 4444 4444', + cvc: '054', + expiryMonth: 10, + expiryYear: 2022, + name: 'Custard Cream', + type: 'Card' + }, + settlementCurrency: 'GBP' +}; + +const OUTPUT_DATA_OPTIONAL = { + shopperEmailAddress: 'i@ms.com', + paymentMethod: { + startYear: 2015, + startMonth: 10, + issueNumber: '555' + }, + billingAddress: { + address2: 'Foobar Town', + address3: 'Foobar City', + telephoneNumber: '1234567890', + postalCode: 'EH7 7HH' + } +}; + +describe('acquirer.worldpay', () => { + describe('payment.exec', () => { + afterEach(() => { + worldpay.worldpayFunction.restore(); + }); + + describe('data mapping', () => { + beforeEach(() => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, null, { + paymentStatus: 'SUCCESS', + orderCode: '123' + }); + }); + + describe('mandatory parameters', () => { + it('are mapped to the correct worldpay parameters', () => payment(INPUT_DATA_MANDATORY) + .then(() => expect(worldpay.worldpayFunction) + .to.be + .calledWith('POST', 'orders', INPUT_DATA_MANDATORY.payee.worldpay.receivingAccountServiceKey, null, OUTPUT_DATA_MANDATORY)) + ); + }); + + describe('optional parameters', () => { + it('are mapped to the correct worldpay parameters', () => { + const input = lodashMerge(INPUT_DATA_MANDATORY, INPUT_DATA_OPTIONAL); + const output = lodashMerge(OUTPUT_DATA_MANDATORY, OUTPUT_DATA_OPTIONAL); + return payment(input) + .then(() => expect(worldpay.worldpayFunction) + .to.be + .calledWith('POST', 'orders', INPUT_DATA_MANDATORY.payee.worldpay.receivingAccountServiceKey, null, output)); + }); + }); + }); + + describe('error mapping', () => { + describe('unknown worldpay error', () => { + it('returns a WorldpayPaymentError object', () => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, null, { + paymentStatus: 'NOTSUCCESS' + }); + return payment(INPUT_DATA_MANDATORY) + .then(() => { + throw new Error('invalid branch'); + }) + .catch((error) => { + expect(error).to.be.instanceof(WorldpayPaymentError); + }); + }); + }); + + describe('known worldpay error', () => { + it('returns a WorldpayPaymentError object', () => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, {customCode: 'BAD_REQUEST'}); + return payment(INPUT_DATA_MANDATORY) + .then(() => { + throw new Error('invalid branch'); + }) + .catch((error) => { + expect(error).to.be.instanceof(WorldpayPaymentError); + }); + }); + }); + }); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.js new file mode 100644 index 0000000..de57723 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.js @@ -0,0 +1,69 @@ +'use strict'; + +/* eslint max-nested-callbacks: ["error", 99] */ + +const utils = require(global.pathPrefix + 'utils.js'); +const makePayment = require('../pay-directly/payment'); +const decryptCard = require('../../../../common/instrument/decrypt-card'); +const daoAccount = require('../../../../common/daoFactory')('collectionPaymentInstrument'); +const daoAddress = require('../../../../common/daoFactory')('collectionAddresses'); +const dataMapper = require('./payment/data-mapper'); +const {INVALID} = require('../../../../common/errorDicts/instruments.json'); +const HttpError = require('../../../../common/HttpError'); + +/** + * This lamda runs the pipeline for making a payment via a stored instrument + * + * @param {Object} data - instrument identification data + * @param {string} instrumentId + * @param {string} userId + * @returns {Promise} from payment.js function + */ +function payment(data, instrumentId, userId) { + // retrieve + return daoAccount.getOneByQuery({ + _id: instrumentId, + UserID: userId, + AccountType: utils.PaymentInstrumentType.CREDIT_DEBIT_PAYMENT_CARD + }) + .then((instrument) => { + // found? + if (!instrument) { + throw new HttpError(INVALID.httpCode, INVALID.internal, INVALID.external); + } + + // decrypt + return decryptCard.decrypt(instrument, data.paymentInstrument.encryptionKey, userId) + .then((decryptedInstrument) => { + // fetch address + return daoAddress.getOneByUUID(decryptedInstrument.CreditDebitCardInfo.BillingAddress) + .then((address) => { + if (!address) { + throw new Error('DB RELATION ERROR: Address'); + } + + // map instrument into payment format + const mappedInstrument = dataMapper.dataMapper( + decryptedInstrument, + address, + { + cv2: data.paymentInstrument.CV2 + } + ); + + // make payment + return makePayment.payment({ + amount: data.amount, + payee: data.payee, + transactionDetails: data.transactionDetails, + paymentInstrument: { + payer: mappedInstrument.payer, + card: mappedInstrument.card + } + }); + }); + }); + }); +} + +module.exports = {payment}; diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.spec.js new file mode 100644 index 0000000..b1e2b0d --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.spec.js @@ -0,0 +1,174 @@ +/* eslint-disable import/no-unassigned-import */ +/* eslint-disable mocha/no-hooks-for-single-case */ +/* eslint-disable global-require */ +/* eslint-disable promise/always-return */ +/* eslint-disable no-throw-literal */ +/* eslint max-nested-callbacks: ["error", 99] */ + +'use strict'; + +const {INVALID} = require('../../../../common/errorDicts/instruments.json'); +const HttpError = require('../../../../common/HttpError'); + +require('../../../../../tools/test/testGlobals'); + +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const rewire = require('rewire'); + +const payment = rewire('./payment'); + +const decryptStub = payment.__get__('decryptCard'); +const daoAccountStub = payment.__get__('daoAccount'); +const daoAddressStub = payment.__get__('daoAddress'); +const dataMapperStub = payment.__get__('dataMapper'); +const makePaymentStub = payment.__get__('makePayment'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); + +describe('acquirers.worldpay.instrument.payment', () => { + describe('errors', () => { + afterEach(() => { + sandbox.restore(); + }); + describe('handles', () => { + it('Throws "invalid" HttpError when no instrument found', () => { + sandbox.stub(daoAccountStub, 'getOneByQuery').resolves(null); + return payment.payment( + { + card: { + id: '1111' + }, + paymentInstrument: {} + }, '1111', '2222' + ) + .then(() => { + throw false; + }) + .catch((error) => { + expect(error).to.be.an.instanceof(HttpError); + expect(error.httpCode).to.equal(INVALID.httpCode); + expect(daoAccountStub.getOneByQuery).to.have.been + .calledOnce + .calledWith({ + _id: '1111', + UserID: '2222', + AccountType: 'Credit/Debit Payment Card' + }); + }); + }); + it('Throws when no address found', () => { + sandbox.stub(daoAccountStub, 'getOneByQuery').resolves({}); + sandbox.stub(decryptStub, 'decrypt').resolves({ + AdditionalInfo: [{}] + }); + sandbox.stub(daoAddressStub, 'getOneByUUID').resolves(null); + return payment.payment( + { + card: { + id: '1111' + }, + paymentInstrument: {} + }, '1111', '1111' + ) + .then(() => { + throw false; + }) + .catch((error) => { + expect(error.toString()).to.include('Address'); + expect(error).to.be.an.instanceof(Error); + }); + }); + }); + }); + + describe('Correctly executes the pipline', () => { + const userId = '3333'; + const accountId = '5a856cafd7f0a522f1eb4dd6'; + const encrypted = { + CreditDebitCardInfo: { + BillingAddress: '5a856cafd7f0a522f1eb4dd7' + } + }; + const decrypted = { + CreditDebitCardInfo: { + BillingAddress: encrypted.CreditDebitCardInfo.BillingAddress + } + }; + const address = {}; + const mappedInstrument = { + card: { + address: {} + }, + payer: {} + }; + const form = { + paymentInstrument: { + encryptionKey: 'xyz' + }, + payee: 34, + amount: {value: 34}, + transactionDetails: 22 + }; + + before(() => { + sandbox.stub(daoAccountStub, 'getOneByQuery').resolves(encrypted); + sandbox.stub(decryptStub, 'decrypt').resolves(decrypted); + sandbox.stub(daoAddressStub, 'getOneByUUID').resolves(address); + sandbox.stub(dataMapperStub, 'dataMapper').returns(mappedInstrument); + sandbox.stub(makePaymentStub, 'payment').resolves(); + return payment.payment(form, accountId, userId); + }); + + after(() => { + sandbox.restore(); + }); + + it('dao Account called correctly', () => { + expect(daoAccountStub.getOneByQuery).to.have.been + .calledOnce + .calledWith({ + _id: accountId, + UserID: userId, + AccountType: 'Credit/Debit Payment Card' + }); + }); + + it('Decrypt called correctly', () => { + expect(decryptStub.decrypt).to.have.been + .calledOnce + .calledWith(encrypted, form.paymentInstrument.encryptionKey, userId); + }); + + it('dao Address called correctly', () => { + expect(daoAddressStub.getOneByUUID).to.have.been + .calledOnce + .calledWith(decrypted.CreditDebitCardInfo.BillingAddress); + }); + + it('data mapper called correctly', () => { + expect(dataMapperStub.dataMapper).to.have.been + .calledOnce + .calledWith(decrypted, address, { + cv2: undefined + }); + }); + + it('calls makePayment correctly', () => { + expect(makePaymentStub.payment).to.have.been + .calledOnce + .calledWith({ + transactionDetails: form.transactionDetails, + amount: form.amount, + paymentInstrument: { + card: mappedInstrument.card, + payer: mappedInstrument.payer + }, + payee: form.payee + }); + }); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.js new file mode 100644 index 0000000..0ad06e7 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.js @@ -0,0 +1,58 @@ +'use strict'; + +/** + * Maps data into payment contract + * + * @param {Object} instrument + * @param {Object} address + * @param {Object} augment additional attributes to place onto the mapping + * @returns {Object} + */ +function dataMapper(instrument, address, augment) { + const cardInfo = instrument.CreditDebitCardInfo; + const output = { + payer: { + firstName: cardInfo.FirstName, + lastName: cardInfo.LastName, + email: cardInfo.Email + }, + card: { + address: { + town: address.Town, + postcode: address.PostCode, + phoneNumber: address.PhoneNumber, + county: address.County + }, + CV2: augment.cv2, + PAN: cardInfo.cardNumber, + expiryDate: cardInfo.expiryMonth + '-' + cardInfo.expiryYear.substr(2), + issueNumber: cardInfo.IssueNumber, + nameOnCard: cardInfo.NameOnAccount + } + }; + + const outputAddress = output.card.address; + const {BuildingNameFlat} = address; + + // address 1 is building name or first line + outputAddress.address1 = BuildingNameFlat || address.Address1; + + // address2 is 1st or 2nd line depeneding if BuildingNameFlat set + const address2 = address['Address' + (BuildingNameFlat ? 1 : 2)]; + if (address2) { + outputAddress.address2 = address2; + } + + // address3 is Address2 if BuildingNameFlat set + if (BuildingNameFlat && address.Address2) { + outputAddress.address3 = address.Address2; + } + + if (cardInfo.startMonth && cardInfo.startYear) { + output.card.startDate = cardInfo.startMonth + '-' + cardInfo.startYear.substr(2); + } + + return output; +} + +module.exports = {dataMapper}; diff --git a/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.spec.js new file mode 100644 index 0000000..f64616c --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.spec.js @@ -0,0 +1,80 @@ +/* eslint-disable max-nested-callbacks */ + +'use strict'; + +const chai = require('chai'); +const {dataMapper} = require('./data-mapper'); + +const expect = chai.expect; + +const INPUT = [ + { + CreditDebitCardInfo: { + cardNumber: '4444 3333 2222 1111', + expiryMonth: '01', + expiryYear: '2020', + startMonth: '01', + startYear: '2000', + IssueNumber: '01', + NameOnAccount: 'Foo', + FirstName: 'John', + LastName: 'Doe', + Email: 'a@a.com' + } + }, + { + Address1: 'Victoria House', + Address2: '15 The Street', + Town: 'Christchurch', + County: 'Dorset', + PhoneNumber: '+44 123 1110000', + PostCode: 'BH23 6AA' + }, + { + cv2: '444' + } +]; + +const INPUT_AUGMENT_ADDRESS = { + BuildingNameFlat: 'Flat 20' +}; + +const OUTPUT = { + payer: { + firstName: 'John', + lastName: 'Doe', + email: 'a@a.com' + }, + card: { + PAN: '4444 3333 2222 1111', + expiryDate: '01-20', + startDate: '01-00', + issueNumber: '01', + nameOnCard: 'Foo', + address: { + address1: 'Victoria House', + address2: '15 The Street', + county: 'Dorset', + phoneNumber: '+44 123 1110000', + town: 'Christchurch', + postcode: 'BH23 6AA' + }, + CV2: '444' + } +}; + +const OUTPUT_AUGMENT_ADDRESS = { + address1: 'Flat 20', + address2: 'Victoria House', + address3: '15 The Street' +}; + +describe('acquirer.worldpay.instrument.payment.dataMapper', () => { + it('maps 2 line address data correctly', () => expect(dataMapper(...INPUT)).to.deep.equal(OUTPUT)); + + it('maps 3 line address data correctly', () => { + INPUT[1] = Object.assign(INPUT[1], INPUT_AUGMENT_ADDRESS); + OUTPUT.card.address = Object.assign(OUTPUT.card.address, OUTPUT_AUGMENT_ADDRESS); + expect(dataMapper(...INPUT)).to.deep.equal(OUTPUT); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.js b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.js new file mode 100644 index 0000000..b5acd5c --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.js @@ -0,0 +1,51 @@ +'use strict'; + +/* eslint max-nested-callbacks: ["error", 99] */ + +const utils = require(global.pathPrefix + 'utils.js'); +const makePayment = require('../pay-directly/payment'); +const decrypt = require('./payment/decrypt'); +const daoAccount = require('../../../../common/daoFactory')('collectionPaymentInstrument'); +const {INVALID} = require('../../../../common/errorDicts/instruments.json'); +const HttpError = require('../../../../common/HttpError'); + +/** + * This lamda runs the pipeline for making a payment via a stored instrument + * + * @param {Object} data - instrument identification data + * @param {string} instrumentId + * @param {string} userId + * @returns {Promise} from payment.js function + */ +function payment(data, instrumentId, userId) { + // retrieve + return daoAccount.getOneByQuery({ + _id: instrumentId, + UserID: userId, + AccountType: utils.PaymentInstrumentType.WORLDPAY_ONLINE_PAYMENTS_ACCOUNT + }) + .then((instrument) => { + // found? + if (!instrument) { + throw new HttpError(INVALID.httpCode, INVALID.internal, INVALID.external); + } + + // decrypt + return decrypt.decrypt(instrument, data.receiveInstrument.encryptionKey, userId) + .then((decryptedInstrument) => { + // make payment + return makePayment.payment({ + amount: data.amount, + payee: { + worldpay: { + receivingAccountServiceKey: decryptedInstrument.WorldpayOnlinePaymentsInfo.ServiceKeyDecrypted + } + }, + transactionDetails: data.transactionDetails, + paymentInstrument: data.paymentInstrument + }); + }); + }); +} + +module.exports = {payment}; diff --git a/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.spec.js new file mode 100644 index 0000000..b851a96 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.spec.js @@ -0,0 +1,127 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable mocha/no-hooks-for-single-case */ + +'use strict'; +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +// eslint-disable-next-line import/no-unassigned-import +require('../../../../../tools/test/testGlobals'); + +const payment = rewire('./payment'); + +const decryptStub = payment.__get__('decrypt'); +const daoAccountStub = payment.__get__('daoAccount'); +const makePaymentStub = payment.__get__('makePayment'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const ENCRYPTION_KEY = '65051f5b-1530-4ddd-aef4-744c337d8ad8'; +const USER_ID = 'dc870f553840a414962875b3'; +const INSTRUMENT_ID = '5a9d370a9d5be1158473caca'; +const SERVICE_KEY_ENCRYPTED = '3::08cb00ec29d80cb5373f2521fe09d5cd0a83e38fed6f59addedd8ac1e9adad18f0b9793a02fb8e3adb2e6d048cf774fefa54f806755f6f2c1cc43e952b307c4b1d015e0dffab62b93f33cffb610e214fa1b6edb59b82eb857c4cf09b9cf30879'; +const SERVICE_KEY_DECRYPTED = 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57'; + +const INSTRUMENT = { + AccountType: 'Worldpay Online Payments Account', + WorldpayOnlinePaymentsInfo: { + ServiceKeyEncrypted: SERVICE_KEY_ENCRYPTED + } +}; +const NO_INSTRUMENT = null; + +const DATA = { + paymentInstrument: { + someMoreFields: 'someMoreFields' + }, + receiveInstrument: { + encryptionKey: ENCRYPTION_KEY + }, + amount: { + value: 100 + }, + transactionDetails: { + someDetails: 'someDetails' + } +}; + +/** + * Wrapper for mocha's `it` testcase function to wait for the result of the + * function before running the expectations. + * + * @param {Object} body - a parmeter of the function + * @param {string} description - The description for the test + * @param {Function} expectation - The expectation fucntion for this test + * + * @returns {Promise} - Promise for the completion of the test + */ +function itP(body, description, expectation) { + it(description, () => { + return payment.payment(body, INSTRUMENT_ID, USER_ID) + .then((accountID) => { + return expectation(accountID); + }) + .catch((error) => { + return expectation(error); + }); + }); +} + +describe('aquirers.worldpay.recieve-with-saved-merchant.payment', () => { + describe('pays with a stored worldpay online merchant', () => { + beforeEach(() => { + sandbox.stub(daoAccountStub, 'getOneByQuery').resolves(INSTRUMENT); + sandbox.stub(makePaymentStub, 'payment').resolves(); + sandbox.spy(decryptStub, 'decrypt'); + }); + + afterEach(() => { + sandbox.restore(); + }); + itP(DATA, 'it finds the instrument', () => { + return expect(daoAccountStub.getOneByQuery).to.have.been + .calledOnce; + }); + itP(DATA, 'it decrypts the data', () => { + return expect(decryptStub.decrypt).to.have.been + .calledOnce + .calledWith( + INSTRUMENT, + DATA.receiveInstrument.encryptionKey, + USER_ID); + }); + itP(DATA, 'calls makePayment correctly', () => { + return expect(makePaymentStub.payment).to.have.been + .calledOnce + .calledWith({ + transactionDetails: DATA.transactionDetails, + amount: DATA.amount, + paymentInstrument: DATA.paymentInstrument, + payee: { + worldpay: { + receivingAccountServiceKey: SERVICE_KEY_DECRYPTED + } + } + }); + }); + describe('Failures', () => { + describe('can\'t find instrument on DB', () => { + beforeEach(() => { + daoAccountStub.getOneByQuery.resolves(NO_INSTRUMENT); + }); + itP(DATA, 'returns error', (error) => { + return expect(error.httpPayload).to.deep.equal({ + description: 'The instrument could not be found, has no access or has expired.', + code: 600 + }); + }); + }); + }); + }); +}); diff --git a/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.js b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.js new file mode 100644 index 0000000..2093387 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.js @@ -0,0 +1,53 @@ +module.exports = { + decrypt +}; + +const _ = require('lodash'); + +const utils = require(global.pathPrefix + 'utils.js'); +const hashString = require('../../../../../common/hashString'); +const {DECRYPTION_FAIL} = require('../../../../../common/errorDicts/instruments.json'); +const HttpError = require('../../../../../common/HttpError'); + +/** + * This function decrypts the various Worldpay details as required and available. + * + * @param {Object} data - the data containing the details to encrypt + * @param {string} key - the key from the instrument + * @param {string} userID - the _id of the user as a string + * + * @returns {?Object} - an object with the decrypted details + * @throws {TypeError} - a type error + * @throws {Error} - an error + */ +function decrypt(data, key, userID) { + return Promise.resolve() + .then(() => { + return hashString.hashString(key).then((hashedKey) => { + // + // Check if there is anything to encrypt + // + if (!data.WorldpayOnlinePaymentsInfo) { + throw new Error('No data set'); + } + if (_.isUndefined(data.WorldpayOnlinePaymentsInfo.ServiceKeyEncrypted) || + data.WorldpayOnlinePaymentsInfo.ServiceKeyEncrypted === '') { + throw new Error('ServiceKeyEncrypted has not been set'); + } + + const decryptTemp = utils.decryptDataV3( + data.WorldpayOnlinePaymentsInfo.ServiceKeyEncrypted, + hashedKey, + userID); + if (!_.isString(decryptTemp)) { + throw new TypeError('Error when decrypting the service key.'); + } + data.WorldpayOnlinePaymentsInfo.ServiceKeyDecrypted = decryptTemp; + return data; + }); + }) + .catch(() => { + throw new HttpError(DECRYPTION_FAIL.httpCode, DECRYPTION_FAIL.internal, DECRYPTION_FAIL.external); + }); +} + diff --git a/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.spec.js b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.spec.js new file mode 100644 index 0000000..6a558d7 --- /dev/null +++ b/node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.spec.js @@ -0,0 +1,131 @@ +/* eslint-disable no-empty */ +/* eslint max-nested-callbacks: ["error", 7] */ + +/** + * Unit testing file for encrypt + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../../../../../tools/test/testGlobals.js'); +const _ = require('lodash'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const decrypt = rewire('./decrypt.js'); +const utilsStub = decrypt.__get__('utils'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); + +const ENCRYPTION_KEY = '65051f5b-1530-4ddd-aef4-744c337d8ad8'; +const HEX_ENCRYPTION_KEY = '1f766adf4b2024537d55a7529e21fc936b9a28715ea749cfda4f41e58617adc6'; +const USER_ID = 'dc870f553840a414962875b3'; + +const FAKE_ENCRYPTED_DETAILS = '3::08cb00ec29d80cb5373f2521fe09d5cd0a83e38fed6f59addedd8ac1e9adad18f0b9793a02fb8e3adb2e6d048cf774fefa54f806755f6f2c1cc43e952b307c4b1d015e0dffab62b93f33cffb610e214fa1b6edb59b82eb857c4cf09b9cf30879'; +const INVALID_ENCRYPTED_DETAILS = '0::08cb00ec29d80cb5373f2521fe09d5cd0a83e38fed6f59addedd8ac1e9adad18f0b9793a02fb8e3adb2e6d048cf774fefa54f806755f6f2c1cc43e952b307c4b1d015e0dffab62b93f33cffb610e214fa1b6edb59b82eb857c4cf09b9cf30879'; + +const SERVICE_KEY = 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57'; + +const DECRYPTED_ACCOUNT = { + WorldpayOnlinePaymentsInfo: { + ServiceKeyEncrypted: FAKE_ENCRYPTED_DETAILS, + ServiceKeyDecrypted: SERVICE_KEY + }, + moreData: 'someMoreData' +}; +const ENCRYPTED_FULL_ACCOUNT = { + WorldpayOnlinePaymentsInfo: {ServiceKeyEncrypted: FAKE_ENCRYPTED_DETAILS}, + moreData: 'someMoreData' +}; +const INVALID_FULL_ACCOUNT = { + WorldpayOnlinePaymentsInfo: {ServiceKeyEncrypted: INVALID_ENCRYPTED_DETAILS}, + moreData: 'someMoreData' + +}; +const INVALID_ENCRYPTED_FULL_ACCOUNT = { + WorldpayOnlinePaymentsInfo: { + ServiceKeyEncrypted: '' + }, + moreData: 'someMoreData' +}; +const NO_DATA = {}; + +/** + * Wrapper for mocha's `it` testcase function to wait for the result of the + * function before running the expectations. + * + * @param {Object} data - a parmeter of the function + * @param {string} description - The description for the test + * @param {Function} expectation - The expectation fucntion for this test + * + * @returns {Promise} - Promise for the completion of the test + */ +function itP(data, description, expectation) { + it(description, () => { + return decrypt.decrypt(data, ENCRYPTION_KEY, USER_ID) + .then((decryptedAccount) => { + return expectation(decryptedAccount); + }) + .catch((error) => { + return expectation(error); + }); + }); +} + +describe('aquirers.worldpay.recieve-with-saved-merchant.create.decrypt', () => { + beforeEach(() => { + sandbox.spy(decrypt, 'decrypt'); + sandbox.spy(utilsStub, 'decryptDataV3'); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('successfully', () => { + describe('with required Worldpay Account fields set', () => { + itP(_.clone(ENCRYPTED_FULL_ACCOUNT), 'decrypting the service key', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .calledOnce + .calledWith(FAKE_ENCRYPTED_DETAILS, HEX_ENCRYPTION_KEY, USER_ID); + }); + itP(_.clone(ENCRYPTED_FULL_ACCOUNT), 'returning decrypted details ', (account) => { + return expect(account).to.deep.equal(DECRYPTED_ACCOUNT); + }); + }); + }); + describe('with a failure', () => { + describe('to decrypt the data', () => { + itP(_.clone(INVALID_FULL_ACCOUNT), 'fails to decrypt the sevice key', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .calledOnce + .calledWith(INVALID_ENCRYPTED_DETAILS, HEX_ENCRYPTION_KEY, USER_ID); + }); + itP(_.clone(INVALID_FULL_ACCOUNT), 'throwing an error', (error) => { + return expect(error.message).to.equal('BRIDGE: INSTRUMENT DECRYPTION FAILURE'); + }); + }); + describe('to send invalid data to decrypt', () => { + itP(INVALID_ENCRYPTED_FULL_ACCOUNT, 'does not try to decrypt anything', () => { + return expect(utilsStub.decryptDataV3).to.not.have.been.called; + }); + itP(INVALID_ENCRYPTED_FULL_ACCOUNT, 'throwing an error', (error) => { + return expect(error.message).to.equal('BRIDGE: INSTRUMENT DECRYPTION FAILURE'); + }); + }); + describe('to send no data to decrypt', () => { + itP(NO_DATA, 'does not try to decrypt anything', () => { + return expect(utilsStub.decryptDataV3).to.not.have.been.called; + }); + itP(NO_DATA, 'throwing an error', (error) => { + return expect(error.message).to.equal('BRIDGE: INSTRUMENT DECRYPTION FAILURE'); + }); + }); + }); +}); diff --git a/node_server/dev_api/controllers/common/errorHandler.js b/node_server/dev_api/controllers/common/errorHandler.js new file mode 100644 index 0000000..9365c52 --- /dev/null +++ b/node_server/dev_api/controllers/common/errorHandler.js @@ -0,0 +1,79 @@ +'use strict'; + +const _ = require('lodash'); + +const config = require(global.configFile); +const HttpError = require('../../common/HttpError'); +const debug = require('debug')('dev_api:controllers:common:errorHandler'); + +/** + * Handles a HttpError response or generates a compatible 500 error + * + * @param {Object} res - the response object + * @param {Object} err - the error + * @param {Object?} logOptions - optional data required to log the error + * @param {Object} logOptions.req - express request object + * @param {Object} logOptions.log - the log object to use (always calls error()) + * @param {string} logOptions.message - message to log + * @param {Object?} logOptions.logInfo - optional additional info to log + * @param {Object} additionalResponse - additional values to return in the HTTP response + * + */ +function errorHandler(res, err, logOptions, additionalResponse) { + debug('error', err); + + // + // Setup default options for logging for any params that are not provided. + // Defaults are: + // log: noop() log.error() + // message: error as a string + // additionalInfo: Empty object + // + const log = _.defaultsDeep( + {}, + logOptions, + { + req: {}, + log: {error: _.noop}, + message: String(err), + logInfo: { + internalError: String(err) + } + } + ); + + // + // Setup the response with defaults, then overwrite if we have known values + // + const errorResponse = { + httpCode: 500, + info: config.isDevEnv ? _.get(err, 'stack', 'Internal Service Error') : 'Internal Service Error', + code: -1 + }; + + if (err instanceof HttpError) { + errorResponse.httpCode = err.httpCode; + errorResponse.info = err.httpPayload.description; + errorResponse.code = err.httpPayload.code; + } + + // + // Log the error + // + const logInfo = _.merge({}, log.logInfo, errorResponse); + log.log.error(log.req, log.message, logInfo); + + // + // Return the error response + // + const response = _.merge( + { + code: errorResponse.code, + info: errorResponse.info + }, + additionalResponse + ); + return res.status(errorResponse.httpCode).json(response); +} + +module.exports = errorHandler; diff --git a/node_server/dev_api/controllers/common/errorHandler.spec.js b/node_server/dev_api/controllers/common/errorHandler.spec.js new file mode 100644 index 0000000..f9bc2c1 --- /dev/null +++ b/node_server/dev_api/controllers/common/errorHandler.spec.js @@ -0,0 +1,960 @@ +/** + * Unit testing file for errorHandler function + */ +'use strict'; + +/* eslint max-nested-callbacks: ["error", 99] */ + +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +const config = require(global.configFile); +const HttpError = require('../../common/HttpError'); +const errorHandler = require('./errorHandler.js'); + +/** + * Set up chai & sinon to simplify the tests + */ +const expect = chai.expect; +const sandbox = sinon.createSandbox(); + +chai.use(sinonChai); + +// Observable log +const log = { + error: sandbox.stub() +}; + +// Fake res object +const res = { + status: sandbox.stub(), + json: sandbox.stub() +}; + +// +// Test values +// +const TEST_LOG_MESSAGE = 'Custom log message'; +const TEST_LOG_ADDITIONAL_MESSAGE = 'Some value'; +const TEST_LOG_INFO = { + additionalString: TEST_LOG_ADDITIONAL_MESSAGE +}; + +const TEST_ADDITIONAL_RESPONSE = 'Some additional response'; +const TEST_ADDITIONAL_INFO = { + additionalResponse: TEST_ADDITIONAL_RESPONSE +}; + +// 1. based on new HttpError() +const TEST_HTTPERROR_HTTP_CODE = 999; +const TEST_HTTPERROR_MESSAGE = 'HttpError name'; +const TEST_HTTPERROR_PAYLOAD = { + code: 123, + description: 'Some description' +}; +const TEST_HTTPERROR = new HttpError(TEST_HTTPERROR_HTTP_CODE, TEST_HTTPERROR_MESSAGE, TEST_HTTPERROR_PAYLOAD); + +const EXPECTED_HTTPERROR_RESPONSE = { + code: 123, + info: 'Some description' +}; + +const EXPECTED_HTTPERROR_RESPONSE_WITH_ADDITIONAL_INFO = { + code: 123, + info: 'Some description', + additionalResponse: TEST_ADDITIONAL_RESPONSE +}; + +// 2. Based on new Error() +const TEST_ERROR_MESSAGE = 'Error name'; +const TEST_ERROR = new Error(TEST_ERROR_MESSAGE); + +// 3. Based on something other than an Error or HttpError +const TEST_NON_ERROR = 'Database failed'; +const EXPECTED_NON_ERROR_RESPONSE = { + code: -1, + info: 'Internal Service Error' +}; +const EXPECTED_NON_ERROR_RESPONSE_WITH_ADDITIONAL_INFO = { + code: -1, + info: 'Internal Service Error', + additionalResponse: TEST_ADDITIONAL_RESPONSE +}; + +let logOptions; + +describe('errorHandler', () => { + beforeEach(() => { + res.status = sandbox.stub().returns(res); + res.json = sandbox.stub().returns(res); + }); + + afterEach(() => { + sandbox.reset(); + }); + + describe('with only log function', () => { + before(() => { + logOptions = { + log + }; + }); + + after(() => { + logOptions = undefined; + }); + + describe('with HttpError', () => { + beforeEach(() => { + errorHandler(res, TEST_HTTPERROR, logOptions); + }); + + it('sets response status from HttpError', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(TEST_HTTPERROR_HTTP_CODE); + }); + + it('sets response json body from HttpError', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_HTTPERROR_RESPONSE); + }); + + it('logs information from HttpError', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + 'Error: ' + TEST_HTTPERROR_MESSAGE, + { + httpCode: TEST_HTTPERROR_HTTP_CODE, + code: EXPECTED_HTTPERROR_RESPONSE.code, + info: EXPECTED_HTTPERROR_RESPONSE.info, + internalError: 'Error: ' + TEST_HTTPERROR_MESSAGE + } + ); + }); + }); + + describe('with Error', () => { + describe('in dev mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = true; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to error.stack', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: TEST_ERROR.stack + })); + }); + + it('logs information from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + 'Error: ' + TEST_ERROR_MESSAGE, + { + httpCode: 500, + code: -1, + info: TEST_ERROR.stack, + internalError: 'Error: ' + TEST_ERROR_MESSAGE + } + ); + }); + }); + + describe('in prod mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = false; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: 'Internal Service Error' + })); + }); + + it('logs information from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + 'Error: ' + TEST_ERROR_MESSAGE, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: 'Error: ' + TEST_ERROR_MESSAGE + } + ); + }); + }); + }); + + describe('with non-Error class based error', () => { + beforeEach(() => { + errorHandler(res, TEST_NON_ERROR, logOptions); + }); + + it('sets response status to 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body to code: -1, info: "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_NON_ERROR_RESPONSE); + }); + + it('logs information from the non-Error error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_NON_ERROR, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: TEST_NON_ERROR + } + ); + }); + }); + }); + + describe('with no log options', () => { + before(() => { + logOptions = undefined; + }); + + describe('with HttpError', () => { + beforeEach(() => { + errorHandler(res, TEST_HTTPERROR, logOptions); + }); + + it('sets response status from HttpError', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(TEST_HTTPERROR_HTTP_CODE); + }); + + it('sets response json body from HttpError', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_HTTPERROR_RESPONSE); + }); + + it('doesn\'t log', () => { + return expect(log.error).to.not.have.been.called; + }); + }); + + describe('with Error', () => { + describe('in dev mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = true; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to error.stack', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: TEST_ERROR.stack + })); + }); + + it('doesn\'t log', () => { + return expect(log.error).to.not.have.been.called; + }); + }); + + describe('in prod mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = false; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: 'Internal Service Error' + })); + }); + + it('doesn\'t log', () => { + return expect(log.error).to.not.have.been.called; + }); + }); + }); + + describe('with non-Error class based error', () => { + beforeEach(() => { + errorHandler(res, TEST_NON_ERROR, logOptions); + }); + + it('sets response status to 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body to code: -1, info: "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_NON_ERROR_RESPONSE); + }); + + it('doesn\'t log', () => { + return expect(log.error).to.not.have.been.called; + }); + }); + }); + + describe('with log function & specific log message', () => { + before(() => { + logOptions = { + log, + message: TEST_LOG_MESSAGE + }; + }); + + after(() => { + logOptions = undefined; + }); + + describe('with HttpError', () => { + beforeEach(() => { + errorHandler(res, TEST_HTTPERROR, logOptions); + }); + + it('sets response status from HttpError', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(TEST_HTTPERROR_HTTP_CODE); + }); + + it('sets response json body from HttpError', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_HTTPERROR_RESPONSE); + }); + + it('logs log message + info from HttpError', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: TEST_HTTPERROR_HTTP_CODE, + code: EXPECTED_HTTPERROR_RESPONSE.code, + info: EXPECTED_HTTPERROR_RESPONSE.info, + internalError: 'Error: ' + TEST_HTTPERROR_MESSAGE + } + ); + }); + }); + + describe('with Error', () => { + describe('in dev mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = true; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to error.stack', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: TEST_ERROR.stack + })); + }); + + it('logs log message + info from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: TEST_ERROR.stack, + internalError: 'Error: ' + TEST_ERROR_MESSAGE + } + ); + }); + }); + + describe('in prod mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = false; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: 'Internal Service Error' + })); + }); + + it('logs log message + info from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: 'Error: ' + TEST_ERROR_MESSAGE + } + ); + }); + }); + }); + + describe('with non-Error class based error', () => { + beforeEach(() => { + errorHandler(res, TEST_NON_ERROR, logOptions); + }); + + it('sets response status to 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body to code: -1, info: "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_NON_ERROR_RESPONSE); + }); + + it('logs log message + info from the non-Error error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: TEST_NON_ERROR + } + ); + }); + }); + }); + + describe('with log function, specific log message, & additional log info', () => { + before(() => { + logOptions = { + log, + message: TEST_LOG_MESSAGE, + logInfo: TEST_LOG_INFO + }; + }); + + after(() => { + logOptions = undefined; + }); + + describe('with HttpError', () => { + beforeEach(() => { + errorHandler(res, TEST_HTTPERROR, logOptions); + }); + + it('sets response status from HttpError', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(TEST_HTTPERROR_HTTP_CODE); + }); + + it('sets response json body from HttpError', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_HTTPERROR_RESPONSE); + }); + + it('logs log message + additional info + info from HttpError', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: TEST_HTTPERROR_HTTP_CODE, + code: EXPECTED_HTTPERROR_RESPONSE.code, + info: EXPECTED_HTTPERROR_RESPONSE.info, + internalError: 'Error: ' + TEST_HTTPERROR_MESSAGE, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + + describe('with Error', () => { + describe('in dev mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = true; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to error.stack', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: TEST_ERROR.stack + })); + }); + + it('logs log message + additional info + info from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: TEST_ERROR.stack, + internalError: 'Error: ' + TEST_ERROR_MESSAGE, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + + describe('in prod mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = false; + errorHandler(res, TEST_ERROR, logOptions); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: 'Internal Service Error' + })); + }); + + it('logs log message + additional info + info from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: 'Error: ' + TEST_ERROR_MESSAGE, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + }); + + describe('with non-Error class based error', () => { + beforeEach(() => { + errorHandler(res, TEST_NON_ERROR, logOptions); + }); + + it('sets response status to 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body to code: -1, info: "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_NON_ERROR_RESPONSE); + }); + + it('logs log message + additional info + info from the non-Error error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: TEST_NON_ERROR, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + }); + describe('with log function, specific log message, additional log info, & additional response info', () => { + before(() => { + logOptions = { + log, + message: TEST_LOG_MESSAGE, + logInfo: TEST_LOG_INFO + }; + }); + + after(() => { + logOptions = undefined; + }); + + describe('with HttpError', () => { + beforeEach(() => { + errorHandler(res, TEST_HTTPERROR, logOptions, TEST_ADDITIONAL_INFO); + }); + + it('sets response status from HttpError', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(TEST_HTTPERROR_HTTP_CODE); + }); + + it('sets response json body from HttpError', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_HTTPERROR_RESPONSE_WITH_ADDITIONAL_INFO); + }); + + it('logs log message + additional info + info from HttpError', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: TEST_HTTPERROR_HTTP_CODE, + code: EXPECTED_HTTPERROR_RESPONSE.code, + info: EXPECTED_HTTPERROR_RESPONSE.info, + internalError: 'Error: ' + TEST_HTTPERROR_MESSAGE, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + + describe('with Error', () => { + describe('in dev mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = true; + errorHandler(res, TEST_ERROR, logOptions, TEST_ADDITIONAL_INFO); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to error.stack', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: TEST_ERROR.stack + })); + }); + + it('includes additional data in json body', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match(TEST_ADDITIONAL_INFO)); + }); + + it('logs log message + additional info + info from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: TEST_ERROR.stack, + internalError: 'Error: ' + TEST_ERROR_MESSAGE, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + + describe('in prod mode', () => { + const previousDevMode = config.isDevEnv; + beforeEach(() => { + config.isDevEnv = false; + errorHandler(res, TEST_ERROR, logOptions, TEST_ADDITIONAL_INFO); + }); + + afterEach(() => { + config.isDevEnv = previousDevMode; + }); + + it('sets response status of 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body `code` to -1', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + code: -1 + })); + }); + + it('sets response json body `info` to "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match({ + info: 'Internal Service Error' + })); + }); + + it('includes additional data in json body', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(sinon.match(TEST_ADDITIONAL_INFO)); + }); + + it('logs log message + additional info + info from Error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: 'Error: ' + TEST_ERROR_MESSAGE, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + }); + + describe('with non-Error class based error', () => { + beforeEach(() => { + errorHandler(res, TEST_NON_ERROR, logOptions, TEST_ADDITIONAL_INFO); + }); + + it('sets response status to 500', () => { + return expect(res.status).to.have.been + .calledOnce + .calledWith(500); + }); + + it('sets response json body to code: -1, info: "Internal Service Error"', () => { + return expect(res.json).to.have.been + .calledOnce + .calledWith(EXPECTED_NON_ERROR_RESPONSE_WITH_ADDITIONAL_INFO); + }); + + it('logs log message + additional info + info from the non-Error error', () => { + return expect(log.error).to.have.been + .calledOnce + .calledWith( + {}, + TEST_LOG_MESSAGE, + { + httpCode: 500, + code: -1, + info: 'Internal Service Error', + internalError: TEST_NON_ERROR, + additionalString: TEST_LOG_ADDITIONAL_MESSAGE + } + ); + }); + }); + }); +}); diff --git a/node_server/dev_api/controllers/instruments/cards/create.js b/node_server/dev_api/controllers/instruments/cards/create.js new file mode 100644 index 0000000..afcd347 --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/create.js @@ -0,0 +1,51 @@ +/* eslint-disable max-nested-callbacks */ +'use strict'; + +const encrypt = require('./create/encrypt'); +const validateCardData = require('../../../common/instrument/validate-card-data.js'); +const daoAccount = require('../../../common/daoFactory')('collectionPaymentInstrument'); +const daoAddress = require('../../../common/daoFactory')('collectionAddresses'); +const dataMapper = require('./create/data-mapper'); +const uuidv4 = require('uuid/v4'); + +/** + * Runs the pipeline for; + * 1. mapping and encrypting. + * 2. creating the address object + * 3. creating the account object + * + * @param {Object} body - contains account and address information + * @param {string} userId - uuid used to tie the account to the user + * @returns {Promise} + * @throws {HttpError} + */ +function create(body, userId) { + return Promise.resolve() + .then(() => { + validateCardData.validateCardData(body); + + // Maps the data + const mappedData = dataMapper.dataMapper(body, userId); + + const cardUsageKey = uuidv4(); + + // Encrypts sensitive data, returning everything including newly encrypted data minus the unencrypted data + return encrypt.encrypt(mappedData, cardUsageKey, userId) + + // Adds the object to database + .then((encryptedData) => { + return daoAddress.createOne(encryptedData.Address) + .then((addressUuid) => { + encryptedData.Account.CreditDebitCardInfo.BillingAddress = addressUuid; + return daoAccount.createOne(encryptedData.Account).then((accountUuid) => { + return { + cardUsageKey, + cardID: accountUuid + }; + }); + }); + }); + }); +} + +module.exports = {create}; diff --git a/node_server/dev_api/controllers/instruments/cards/create.spec.js b/node_server/dev_api/controllers/instruments/cards/create.spec.js new file mode 100644 index 0000000..ecf8e22 --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/create.spec.js @@ -0,0 +1,131 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable mocha/no-hooks-for-single-case */ + +'use strict'; +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +// eslint-disable-next-line import/no-unassigned-import +require('../../../../tools/test/testGlobals'); + +const create = rewire('./create'); + +const encryptStub = create.__get__('encrypt'); +const daoAccountStub = create.__get__('daoAccount'); +const daoAddressStub = create.__get__('daoAddress'); +const dataMapperStub = create.__get__('dataMapper'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const ADDRESS_UUID = 'df324Qwef3'; +const ACCOUNT_UUID = 'afw34fwsfs'; +const UNMAPPED_BODY = { + card: { + startDate: '01-00', + expiryDate: '01-99' + } +}; +const MAPPED_ACCOUNT = { + Account: { + accountInfo: 'someAccounInfo', + CreditDebitCardInfo: { + someCardInfo: 'someCardInfo' + } + }, + Address: {addressInfo: 'someAddressInfo'} +}; +const ENCRYPTED_ACCOUNT = { + Account: { + encryptedAccountInfo: 'someEncryptedAccountInfo', + CreditDebitCardInfo: { + someEncryptedCardInfo: 'someEncryptedCardInfo' + } + }, + Address: {addressInfo: 'someAddressInfo'} +}; + +const ACCOUNT_PLUS_UUIDS = { + encryptedAccountInfo: 'someEncryptedAccountInfo', + CreditDebitCardInfo: { + someEncryptedCardInfo: 'someEncryptedCardInfo', + BillingAddress: ADDRESS_UUID + } +}; + +/** + * Wrapper for mocha's `it` testcase function to wait for the result of the + * function before running the expectations. + * + * @param {Object} body - a parmeter of the function + * @param {string} description - The description for the test + * @param {Function} expectation - The expectation fucntion for this test + * + * @returns {Promise} - Promise for the completion of the test + */ +function itP(body, description, expectation) { + it(description, () => { + return create.create(body, '1') + .then((accountID) => { + return expectation(accountID); + }) + .catch((error) => { + return expectation(error); + }); + }); +} + +describe('instruments.cards.create', () => { + let clock; + before(() => { + const now = new Date(2020, 1); + clock = sinon.useFakeTimers(now.getTime()); + }); + after(() => { + clock.restore(); + }); + beforeEach(() => { + sandbox.stub(daoAccountStub, 'createOne').resolves(ACCOUNT_UUID); + sandbox.stub(daoAddressStub, 'createOne').resolves(ADDRESS_UUID); + sandbox.stub(encryptStub, 'encrypt').resolves(ENCRYPTED_ACCOUNT); + sandbox.stub(dataMapperStub, 'dataMapper').returns(MAPPED_ACCOUNT); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('creates an account and address object', () => { + itP(UNMAPPED_BODY, 'it maps the data', () => { + return expect(dataMapperStub.dataMapper).to.have.been + .calledOnce + .calledWith(UNMAPPED_BODY, '1'); + }); + itP(UNMAPPED_BODY, 'it encrypts the data', () => { + return expect(encryptStub.encrypt).to.have.been + .calledOnce + .calledWith( + MAPPED_ACCOUNT, + sinon.match.string, + '1'); + }); + itP(UNMAPPED_BODY, 'it creates the address object', () => { + return expect(daoAddressStub.createOne).to.have.been + .calledOnce + .calledWith(ENCRYPTED_ACCOUNT.Address); + }); + itP(UNMAPPED_BODY, 'it creates the account object', () => { + return expect(daoAccountStub.createOne).to.have.been + .calledOnce + .calledWith(ACCOUNT_PLUS_UUIDS); + }); + itP(UNMAPPED_BODY, 'it returns the account ID', (accountID) => { + return expect(accountID).to.include({ + cardID: ACCOUNT_UUID}); + }); + }); +}); diff --git a/node_server/dev_api/controllers/instruments/cards/create/data-mapper.js b/node_server/dev_api/controllers/instruments/cards/create/data-mapper.js new file mode 100644 index 0000000..2fa5c57 --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/create/data-mapper.js @@ -0,0 +1,80 @@ +'use strict'; +/* eslint-disable no-negated-condition */ + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const anon = require(global.pathPrefix + '../utils/anon.js'); + +/** + * Maps a generic payment instrument interface into a DB 'schema'. + * + * @param {Object} paymentInstrument data + * @returns {Object} mapped data + */ +function dataMapper(paymentInstrument, userID) { + const account = mainDB.blankCreditDebitCard(); + const address = mainDB.blankAddress(); + + const output = { + Account: account, + Address: address + }; + + const updateTime = new Date(); + + address.Town = paymentInstrument.card.address.town; + address.PostCode = paymentInstrument.card.address.postcode; + address.UserID = userID; + address.DateAdded = updateTime; + address.LastUpdate = updateTime; + + const {address3} = paymentInstrument.card.address; + if (!address3) { + if (paymentInstrument.card.address.address1) { + address.Address1 = paymentInstrument.card.address.address1; + } + if (paymentInstrument.card.address.address2) { + address.Address2 = paymentInstrument.card.address.address2; + } + } else { + address.BuildingNameFlat = paymentInstrument.card.address.address1; + address.Address1 = paymentInstrument.card.address.address2; + address.Address2 = paymentInstrument.card.address.address3; + } + + if (paymentInstrument.card.address.county) { + address.County = paymentInstrument.card.address.county; + } + if (paymentInstrument.card.address.phoneNumber) { + address.PhoneNumber = paymentInstrument.card.address.phoneNumber; + } + + account.UserID = userID; + account.CreditDebitCardInfo.CardPAN = anon.anonymiseCardPAN(paymentInstrument.card.PAN); + account.CreditDebitCardInfo.CardPanToBeEncrypted = paymentInstrument.card.PAN; + account.CreditDebitCardInfo.CardExpiryToBeEncrypted = paymentInstrument.card.expiryDate; + account.CreditDebitCardInfo.NameOnAccount = paymentInstrument.card.nameOnCard; + if (paymentInstrument.description) { + account.Description = paymentInstrument.description; + } + if (paymentInstrument.card.startDate) { + account.CreditDebitCardInfo.CardValidFromToBeEncrypted = paymentInstrument.card.startDate; + } + if (paymentInstrument.card.issueNumber) { + account.CreditDebitCardInfo.IssueNumberToBeEncrypted = paymentInstrument.card.issueNumber; + } + + account.VendorAccountName = 'Credit/Debit Card'; + const cardDetails = utils.identifyCard(paymentInstrument.card.PAN); + account.VendorID = cardDetails.type; + account.IconLocation = cardDetails.icon; + account.LastUpdate = updateTime; + + account.CreditDebitCardInfo.Email = paymentInstrument.payer.email; + account.CreditDebitCardInfo.FirstName = paymentInstrument.payer.firstName; + account.CreditDebitCardInfo.LastName = paymentInstrument.payer.lastName; + + return output; +} + +module.exports = {dataMapper}; diff --git a/node_server/dev_api/controllers/instruments/cards/create/data-mapper.spec.js b/node_server/dev_api/controllers/instruments/cards/create/data-mapper.spec.js new file mode 100644 index 0000000..5b7ee8d --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/create/data-mapper.spec.js @@ -0,0 +1,144 @@ +/* eslint-disable max-nested-callbacks */ +'use strict'; + +const sinon = require('sinon'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); +const _ = require('lodash'); + +const dataMapper = rewire('./data-mapper'); +const mainDBStub = dataMapper.__get__('mainDB'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +let clock; + +const USER_ID = 'agfwf232f'; + +const MIN_INPUT_DATA = { + payer: { + firstName: 'John', + lastName: 'Doe', + email: 'a@b.com' + }, + card: { + nameOnCard: 'Mr Joe Bloggs', + PAN: '4444333322221111', + expiryDate: '01-00', + address: { + address1: 'Flat 20', + address2: 'Victoria House', + town: 'Christchurch', + postcode: 'BH23 6AA' + } + } +}; +const MAX_INPUT_DATA = _.defaultsDeep( + { + description: 'red card', + card: { + startDate: '01-00', + issueNumber: 1, + address: { + address3: '15 The Street', + county: 'Dorset', + phoneNumber: '+44 123 1110000' + } + } + }, + MIN_INPUT_DATA +); + +let MIN_RETURNED_DATA; +let MAX_RETURNED_DATA; + +describe('acquirer.worldpay.instrument.create', () => { + describe('data mapping', () => { + before(() => { + clock = sinon.useFakeTimers(); + + sandbox.stub(mainDBStub, 'blankAddress').returns({}); + + MIN_RETURNED_DATA = { + Account: { + UserID: USER_ID, + AccountType: 'Credit/Debit Payment Card', + IconLocation: 'VISA_CREDIT.png', + Description: '', + PaymentsAccount: 1, + ReceivingAccount: 0, + VendorAccountName: 'Credit/Debit Card', + VendorID: 'Visa', + LastUpdate: new Date(), + LastVersion: 1, + APIVersion: '0.0.0.0-unittest', + Integrity: null, + CreditDebitCardInfo: { + Email: 'a@b.com', + FirstName: 'John', + LastName: 'Doe', + CardExpiryToBeEncrypted: '01-00', + CardPAN: '4*** **** **** *111', + CardPanToBeEncrypted: '4444333322221111', + NameOnAccount: 'Mr Joe Bloggs', + BillingAddress: '', + CardPANEncrypted: '', + CardExpiryEncrypted: '', + IssueNumberEncrypted: '', + CardValidFromEncrypted: '' + } + }, + Address: { + UserID: USER_ID, + Address1: 'Flat 20', + Address2: 'Victoria House', + Town: 'Christchurch', + PostCode: 'BH23 6AA', + DateAdded: new Date(), + LastUpdate: new Date() + } + }; + MAX_RETURNED_DATA = _.defaultsDeep( + { + Account: { + Description: 'red card', + CreditDebitCardInfo: { + CardValidFromToBeEncrypted: '01-00', + IssueNumberToBeEncrypted: 1 + } + }, + Address: { + BuildingNameFlat: 'Flat 20', + Address1: 'Victoria House', + Address2: '15 The Street', + County: 'Dorset', + PhoneNumber: '+44 123 1110000' + } + }, + MIN_RETURNED_DATA + ); + }); + + after(() => { + clock.restore(); + sandbox.restore(); + }); + it('returns minimum set of mapped data', () => { + const output = dataMapper.dataMapper(MIN_INPUT_DATA, USER_ID); + expect(output) + .to.deep.equal(MIN_RETURNED_DATA); + } + ); + it('returns maximum set of mapped data', () => { + const output = dataMapper.dataMapper(MAX_INPUT_DATA, USER_ID); + expect(output) + .to.deep.equal(MAX_RETURNED_DATA); + } + ); + }); +}); diff --git a/node_server/dev_api/controllers/instruments/cards/create/encrypt.js b/node_server/dev_api/controllers/instruments/cards/create/encrypt.js new file mode 100644 index 0000000..8962162 --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/create/encrypt.js @@ -0,0 +1,26 @@ +'use strict'; + +const encryption = require('../../../../../utils/encryption'); +const hashString = require('../../../../common/hashString'); + +/** + * Maps a instrument decryption call result or error + * + * @param {string} decryptedInstrument - instrument data + * @param {string} encryptionKey - encryption key + * @param {string} userId - authentication id + * @returns {Promise} encrypted instrument + */ +function encrypt(decryptedInstrument, encryptionKey, userId) { + // encrypt the instrument + return Promise.resolve() + .then(() => { + return hashString.hashString(encryptionKey).then((hashedKey) => { + // this function should be async for future compatibility + const instrument = encryption.encryptCardMaintainingAccount(decryptedInstrument, hashedKey, userId); + return instrument; + }); + }); +} + +module.exports = {encrypt}; diff --git a/node_server/dev_api/controllers/instruments/cards/create/encrypt.spec.js b/node_server/dev_api/controllers/instruments/cards/create/encrypt.spec.js new file mode 100644 index 0000000..2c8bdfe --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/create/encrypt.spec.js @@ -0,0 +1,28 @@ +/* eslint-disable mocha/no-hooks-for-single-case */ +'use strict'; + +const sinon = require('sinon'); +const rewire = require('rewire'); + +const encrypt = rewire('./encrypt'); + +const encryptionStub = encrypt.__get__('encryption'); +const chai = require('chai'); + +const sandbox = sinon.createSandbox(); + +const expect = chai.expect; + +describe('instruments.cards.create.encrypt', () => { + after(() => { + sandbox.restore(); + }); + it('it returns encrypted keys if instrument is valid', () => { + sandbox.stub(encryptionStub, 'encryptCardMaintainingAccount').returns(789); + + return encrypt.encrypt({}, '1', '1') + .then((instrument) => { + return expect(instrument).to.equal(789); + }); + }); +}); diff --git a/node_server/dev_api/controllers/instruments/cards/list.js b/node_server/dev_api/controllers/instruments/cards/list.js new file mode 100644 index 0000000..a00ac59 --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/list.js @@ -0,0 +1,91 @@ +/* eslint-disable filenames/match-exported */ +/* eslint-disable lodash/prefer-lodash-typecheck */ + +'use strict'; + +const daoPaymentInstrument = require('../../../common/daoFactory')('collectionPaymentInstrument'); + +const exporter = {}; + +/** + * Make a payment using a saved instrument. + * + * @param {Object} query parameters + * @param {!Object} daoProjection parameters + * @returns {Promise} + */ +function list(query, daoProjection) { + if (!query || typeof query !== 'object') { + throw new TypeError('first arg must be an object'); + } + + const daoQuery = {}; + + // prevents mixed projections + const hasProjection = daoProjection && + typeof daoProjection === 'object' && + Object.keys(daoProjection).length; + + if (!hasProjection) { + daoProjection = {}; + } + + // filters + // this disconnects higher level functions from DB property names + [ + ['userId', 'UserID'], + ['accountType', 'AccountType'] + ].forEach((mapping) => { + const value = query[mapping[0]]; + if (typeof value !== 'undefined') { + daoQuery[mapping[1]] = value; + if (!hasProjection) { + daoProjection[mapping[1]] = 0; + } + } + }); + + // can't select everything! + if (!Object.keys(daoQuery).length) { + throw new TypeError('invalid filters resulted in empty query'); + } + + return daoPaymentInstrument + .getByQuery(daoQuery, daoProjection); +} + +/** + * Lists by card (and maps the result). + * + * @param {Object} query parameters + * @returns {Promise} + */ +function listCards(query) { + return exporter.list( + query, + { + _id: 1, + Description: 1, + 'CreditDebitCardInfo.CardPAN': 1 + }) + .then((results) => { + return { + data: results.map((record) => { + const returnObject = {}; + returnObject.cardID = record._id; + returnObject.obfuscatedCardPAN = record.CreditDebitCardInfo.CardPAN; + + if (record.Description && record.Description.length) { + returnObject.description = record.Description; + } + + return returnObject; + }) + }; + }); +} + +exporter.list = list; +exporter.listCards = listCards; + +module.exports = exporter; diff --git a/node_server/dev_api/controllers/instruments/cards/list.spec.js b/node_server/dev_api/controllers/instruments/cards/list.spec.js new file mode 100644 index 0000000..1477e62 --- /dev/null +++ b/node_server/dev_api/controllers/instruments/cards/list.spec.js @@ -0,0 +1,147 @@ +/* eslint-disable import/no-unassigned-import */ +/* eslint-disable mocha/no-hooks-for-single-case */ +/* eslint max-nested-callbacks: ["error", 99] */ + +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +require('../../../../tools/test/testGlobals'); +const rewire = require('rewire'); + +const {expect} = chai; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); + +const listMod = rewire('./list'); +const daoPaymentInstrument = listMod.__get__('daoPaymentInstrument'); + +const daoResult = [{ + _id: 23, + Description: 'red card', + CreditDebitCardInfo: { + CardPAN: '1*** **** **** *333' + } +}]; + +const userEmptyStringDaoResult = [{ + _id: 23, + Description: '', + CreditDebitCardInfo: { + CardPAN: '1*** **** **** *333' + } +}]; + +describe('instruments.list', () => { + after(() => { + sandbox.restore(); + }); + before(() => { + sandbox.stub(daoPaymentInstrument, 'getByQuery').resolves(daoResult); + }); + + describe('list', () => { + it('throws if first argument is not an oject', () => { + expect(() => listMod.list()).to.throw(TypeError); + expect(() => listMod.list('')).to.throw(TypeError); + }); + + it('throws if no recognised filters', () => { + expect(() => listMod.list({ + something: false + })).to.throw(TypeError); + }); + + it('calls daoFactory with mapped args and maps return', () => listMod.list( + { + userId: 'dc870f553840a414962875b3', + accountType: 'Credit/Debit Payment Card' + }, + { + foo: 1 + }) + .then((result) => { + expect(daoPaymentInstrument.getByQuery) + .to.be + .calledWith( + { + UserID: 'dc870f553840a414962875b3', + AccountType: 'Credit/Debit Payment Card' + }, + { + foo: 1 + } + ); + return expect(result).to.equal(daoResult); + })); + + it('populates projection list automatically', () => listMod.list( + { + userId: 'dc870f553840a414962875b3', + accountType: 'Credit/Debit Payment Card' + }) + .then((result) => { + expect(daoPaymentInstrument.getByQuery) + .to.be + .calledWith( + { + UserID: 'dc870f553840a414962875b3', + AccountType: 'Credit/Debit Payment Card' + }, + { + UserID: 0, + AccountType: 0 + } + ); + return expect(result).to.equal(daoResult); + })); + }); + + describe('listCard', () => { + before(() => { + sandbox.stub(listMod, 'list'); + }); + after(() => { + sandbox.restore(); + }); + + const arg = { + userId: 'dc870f553840a414962875b3', + accountType: 'Credit/Debit Payment Card' + }; + + it('calls list with args and maps data correctly', () => { + listMod.list.resolves(daoResult); + + return listMod.listCards(arg) + .then((result) => { + expect(listMod.list) + .to.be + .calledWith(arg, + { + _id: 1, + Description: 1, + 'CreditDebitCardInfo.CardPAN': 1 + } + ); + return expect(result.data[0]).to.deep.equal({ + cardID: 23, + description: 'red card', + obfuscatedCardPAN: '1*** **** **** *333' + }); + }); + }); + it('calls list with args and maps data correctly with a empty string for a UserAccountName', () => { + listMod.list.resolves(userEmptyStringDaoResult); + + return listMod.listCards(arg) + .then((result) => { + return expect(result.data[0]).to.deep.equal({ + cardID: 23, + obfuscatedCardPAN: '1*** **** **** *333' + }); + }); + }); + }); +}); diff --git a/node_server/dev_api/controllers/paycodes/create.js b/node_server/dev_api/controllers/paycodes/create.js new file mode 100644 index 0000000..e69de29 diff --git a/node_server/dev_api/controllers/paycodes/create.spec.js b/node_server/dev_api/controllers/paycodes/create.spec.js new file mode 100644 index 0000000..e69de29 diff --git a/node_server/dev_api/controllers/paycodes/create/data-mapper.js b/node_server/dev_api/controllers/paycodes/create/data-mapper.js new file mode 100644 index 0000000..e69de29 diff --git a/node_server/dev_api/controllers/paycodes/create/data-mapper.spec.js b/node_server/dev_api/controllers/paycodes/create/data-mapper.spec.js new file mode 100644 index 0000000..e69de29 diff --git a/node_server/dev_api/controllers/payment_instruments_controller.js b/node_server/dev_api/controllers/payment_instruments_controller.js new file mode 100644 index 0000000..c390076 --- /dev/null +++ b/node_server/dev_api/controllers/payment_instruments_controller.js @@ -0,0 +1,218 @@ +/* +* @fileOverview Respond to commands that relate to payment instruments +*/ +'use strict'; + +const _ = require('lodash'); +const payToWorldpayMerchant = require('./acquirers/worldpay/recieve-with-saved-merchant/payment'); +const payWithSavedCard = require('./acquirers/worldpay/pay-with-saved-card/payment'); +const createWorldPayMerchant = require('./acquirers/worldpay/create-merchant/create'); +const createCard = require('./instruments/cards/create'); +const listMod = require('./instruments/cards/list'); +const HttpError = require('../common/HttpError'); +const debug = require('debug')('dev_api:controllers:payment_instruments_controller'); +const commonErrorHandler = require('./common/errorHandler'); +const log = require('../../utils/logging.js')(__filename, 'payments:instruments:worldpay'); + +const utils = require(global.pathPrefix + 'utils.js'); + +/** + * Save the card + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function saveCardDetails(req, res) { + return createCard.create(req.body, req.session.data.user) + .then((outcome) => { + log.info(req, 'Successfully saved card', { + cardID: outcome.cardID + }); + return res.status(201).json(outcome); + }) + .catch((error) => commonErrorHandler(res, error, { + req, + log, + message: 'Failed to store card details.' + })); +} + +/** + * Save a worldpay receiving account + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function saveWorldpayReceivingAccount(req, res) { + return createWorldPayMerchant.create(req.body, req.session.data.user) + .then((outcome) => { + log.info(req, 'Successfully saved Worldpay online merchant', { + ID: outcome.ID + }); + return res.status(201).json(outcome); + }) + .catch((error) => commonErrorHandler(res, error, { + req, + log, + message: 'Failed to store Worldpay online merchant details.' + })); +} + +/** + * Create a paycode for an instrument. + * Currently just a stub function. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + +function createPaycode(req, res) { + log.info(req, 'createPaycode() - Stub function'); + return res.status(500).json(); +} + +/** + * Logs the correct information + * Implements the Worldpay payment to a saved merchant + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + +function makeWorldpayPaymentToSavedMerchant(req, res) { + /** + * Initial details of the request we are about to make + */ + const logInfo = { + instrumentID: req.swagger.params.instrumentID.value, + totalAmount: req.swagger.params.body.value.amount.value, + currency: 'GBP' + }; + + return payToWorldpayMerchant.payment(req.swagger.params.body.value, req.swagger.params.instrumentID.value, req.session.data.user) + .then((outcome) => { + /** + * Successful response, so log the extra info we need. + */ + logInfo.worldpayOrderCode = outcome.transaction.id; + logInfo.cardSchemeName = outcome.additionalInfo.cardSchemeName; + logInfo.riskScore = outcome.additionalInfo.riskScore; + log.info(req, 'Successful stored card payment', logInfo); + + return res.status(200).json({transaction: outcome.transaction}); + }) + .catch((error) => { + debug(error); + let additionalResponse; + + // + // If this is an HttpError we can get additional information for the response + // + if (error instanceof HttpError) { + _.defaults(logInfo, { + extraInfo: _.pick(error.acquirerInfo, ['httpStatusCode', 'customCode', 'message']) + }); + + additionalResponse = { + response: error.worldpayResponse + }; + } + + return commonErrorHandler( + res, + error, + { + req, + log, + message: 'Unsuccessful stored card payment request', + logInfo + }, + additionalResponse + ); + }); +} + +/** + * Logs the correct information + * Implements the Worldpay payment with a saved card + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + +function makeWorldpayPaymentWithSavedCard(req, res) { + /** + * Initial details of the request we are about to make + */ + const logInfo = { + instrumentID: req.swagger.params.instrumentID.value, + totalAmount: req.swagger.params.body.value.amount.value, + currency: 'GBP' + }; + + return payWithSavedCard.payment(req.body, req.swagger.params.instrumentID.value, req.session.data.user) + .then((outcome) => { + /** + * Successful response, so log the extra info we need. + */ + logInfo.worldpayOrderCode = outcome.transaction.id; + logInfo.cardSchemeName = outcome.additionalInfo.cardSchemeName; + logInfo.riskScore = outcome.additionalInfo.riskScore; + log.info(req, 'Successful stored card payment', logInfo); + + return res.status(200).json({transaction: outcome.transaction}); + }) + .catch((error) => { + debug(error); + let additionalResponse; + + // + // If this is an HttpError we can get additional information for the response + // + if (error instanceof HttpError) { + _.defaults(logInfo, { + extraInfo: _.pick(error.acquirerInfo, ['httpStatusCode', 'customCode', 'message']) + }); + + additionalResponse = { + response: error.worldpayResponse + }; + } + + return commonErrorHandler( + res, + error, + { + req, + log, + message: 'Unsuccessful stored card payment request', + logInfo + }, + additionalResponse + ); + }); +} + +/** + * Lists instruments available to a client. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function listCards(req, res) { + return listMod.listCards({ + userId: req.session.data.user, + accountType: utils.PaymentInstrumentType.CREDIT_DEBIT_PAYMENT_CARD + }) + .then((outcome) => res.status(200).json(outcome)) + .catch((error) => commonErrorHandler(res, error)); +} + +module.exports = { + listCards, + saveCardDetails, + saveWorldpayReceivingAccount, + makeWorldpayPaymentWithSavedCard, + makeWorldpayPaymentToSavedMerchant, + createPaycode +}; diff --git a/node_server/dev_api/controllers/test_controller.js b/node_server/dev_api/controllers/test_controller.js new file mode 100644 index 0000000..6c676cf --- /dev/null +++ b/node_server/dev_api/controllers/test_controller.js @@ -0,0 +1,29 @@ +/** + * @fileOverview A simple file to respond to calls to test functions + */ +'use strict'; + +const log = require('../../utils/logging')(__filename, 'dev:controller:test'); + +module.exports = { + test +}; + +/** + * Trivial implementation of test to allow it to respond in production environment + * where automatic stub responses are disabled. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function test(req, res) { + log.info(req, 'Example info comment'); + log.error( + req, + 'Example error comment', + { + otherParm: 'an additional example paramter' + } + ); + return res.status(200).json(); +} diff --git a/node_server/dev_api/controllers/worldpay_transaction_controller.js b/node_server/dev_api/controllers/worldpay_transaction_controller.js new file mode 100644 index 0000000..e08b9e7 --- /dev/null +++ b/node_server/dev_api/controllers/worldpay_transaction_controller.js @@ -0,0 +1,96 @@ +/** + * @fileOverview Worldpay transaction controller + * + * The functions here pick the data required for the transaction. + * The returned will be response readied or a HttpError. No massaging should occur here. + */ +'use strict'; + +const _ = require('lodash'); +const payDirectly = require('./acquirers/worldpay/pay-directly/payment'); +const HttpError = require('../common/HttpError'); +const commonErrorHandler = require('./common/errorHandler'); +const log = require('../../utils/logging.js')(__filename, 'payments:direct:worldpay'); + +/** + * Common error formatting + * + * @param {Object} req - Express request object (for log-related info) + * @param {Object} res - Express response object + * @param {Error} error - an object which inherits from Error + * @param {Object} logInfo - The base log info for the request + * @private + */ +function handleError(req, res, error, logInfo) { + let additionalResponse; + + // + // If this is an HttpError we can get additional information for the response + // + if (error instanceof HttpError) { + _.defaults(logInfo, { + extraInfo: _.pick(error.acquirerInfo, ['httpStatusCode', 'customCode', 'message']) + }); + + additionalResponse = { + response: error.worldpayResponse + }; + } + + return commonErrorHandler( + res, + error, + { + req, + log, + message: 'Unsuccessful payment request', + logInfo + }, + additionalResponse + ); +} + +/** + * Make a payment (not to be confused with making a worldpay payment with a saved instrument!). + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function worldpayPayment(req, res) { + /** + * Initial details of the request we are about to make + */ + const payeeInfo = _.pick(req.swagger.params.body.value.paymentInstrument.payer, ['email', 'firstName', 'lastName']); + const logInfo = _.merge( + {}, + payeeInfo, + { + totalAmount: req.swagger.params.body.value.amount.value, + currency: 'GBP' + }); + + return payDirectly.payment(req.body) + .then((outcome) => { + /** + * Successful response, so log the extra info we need. + */ + logInfo.worldpayOrderCode = outcome.transaction.id; + logInfo.cardSchemeName = outcome.additionalInfo.cardSchemeName; + logInfo.riskScore = outcome.additionalInfo.riskScore; + log.info(req, 'Successful payment', logInfo); + + /** + * Need to filter the response to just the transaction object. + */ + const response = _.pick(outcome, 'transaction'); + + return res.status(200).json(response); + }) + .catch((error) => { + return handleError(req, res, error, logInfo); + }); +} + +module.exports = { + worldpayPayment +}; diff --git a/node_server/dev_api/dev_server.js b/node_server/dev_api/dev_server.js new file mode 100644 index 0000000..ca862fc --- /dev/null +++ b/node_server/dev_api/dev_server.js @@ -0,0 +1,219 @@ +/* eslint-disable no-unneeded-ternary */ + +'use strict'; + +/** + * The core page for the configuration and deployment of the API server for + * the payments dev API. + * + * The API server is powered by a Swagger API definition: + * @see {@link http://swagger.io} + * + * Express middleware is then used to take the Swagger API definition and + * handle most of the essential but repetitive parts of the API: + * - Connecting routes to handler functions + * - Checking security + * - Validating paramters + * - Validating reponses + * + * In development mode there is also middleware to serve interactive API + * documentation and the API doc itself. + */ +const _ = require('lodash'); +const compression = require('compression'); +const morgan = require('morgan'); // Logging middleware by expressjs +const express = require('express'); +const swaggerTools = require('swagger-tools'); +const RateLimit = require('express-rate-limit'); +const uniqueIdMiddleware = require('./uniqueIdMiddleware'); + +// +// We need to explicitly initialise the swagger-ui middleware ourselves to work +// around issues with base paths. So we need to require the file directly. +// +const swaggerUi = require('../node_modules/swagger-tools/middleware/swagger-ui'); + +const config = require(global.configFile); +const security = require('./security.js'); + +const errorHandler = require(global.pathPrefix + '../swagger_api/api_error_handler.js'); +const initMorgan = require(global.pathPrefix + '../utils/init_morgan.js'); + +// +// Export the router +// +module.exports = { + init +}; + +// +// Swagger Router configuration +// @see {@link https://github.com/apigee-127/swagger-tools/blob/master/docs/Middleware.md#swagger-router} +// +const swaggerRouterOptions = { + // @member {String} - path to the controllers + controllers: global.rootPath + 'dev_api/controllers', + + // @member {Boolean} - enable autogenerated stubs for dev environment + useStubs: config.isDevEnv +}; + +// +// Swagger Validator configuration options +// @see {@link https://github.com/apigee-127/swagger-tools/blob/master/docs/Middleware.md#swagger-validator} +// +const swaggerValidatorOptions = { + // @member{Boolean} - validate responses as well as requests + // swagger stubs don't match the validation entirely, so responses can't + // be validated if they are enabled. + validateResponse: swaggerRouterOptions.useStubs ? true : true +}; + +// +// Load the Swagger API defintion file +// +const swaggerDoc = require('./config/swagger.json'); + +// +// We are going to be used as an express router under /dev so remove that from +// the front of the base path in the swagger API definition. If we don't +// remove it we end up trying to handle a path of /dev/dev/v0/... +// +const swaggerDocUnderRouter = _.cloneDeep(swaggerDoc); +swaggerDocUnderRouter.basePath = swaggerDocUnderRouter.basePath.replace('/dev', ''); + +/** + * Function to intialise the swagger tools for serving the swagger-based + * integration API. + * + * @returns {Object} - router with middleware included + */ +function init() { + // + // Initialise the router we will be using + // + const router = express.Router(); + + // + // Initialise morgan configuration + // + initMorgan.init(); + + // + // Rate limiting options + // Warning: we must clone the value from config so that when we change the + // keyGenerator etc. it doesn't affect other places using the same + // config. + // + const rateLimitConfig = _.clone(config.rateLimits.api); + rateLimitConfig.keyGenerator = function(req) { + // + // Limit per-token if we have a token. Otherwise limit per ip + // + const token = req.header('authorization'); + if (token) { + return token; + } else { + return req.ip; + } + }; + rateLimitConfig.handler = function(req, res) { + // Always send a JSON response + res.status(rateLimitConfig.statusCode).json({ + code: 30500, + description: 'Rate limit reached. Please wait and try again.' + }); + }; + const limiter = new RateLimit(rateLimitConfig); + + // + // Initialize the Swagger middleware from the Swagger API definition. + // This is asynchronous so we need to wait until its done before configuring + // all the express middleware we will use for managing the API + // + swaggerTools.initializeMiddleware(swaggerDocUnderRouter, (middleware) => { + // + // Compression middleware + // + router.use(compression()); + + // + // Unique id middleware + // + router.use(uniqueIdMiddleware); + + // + // Logging middleware + // + router.use(morgan('bridge-combined', { + stream: initMorgan.writeableStream() + })); + + // + // Middleware to interpret Swagger resources and attach metadata to request + // - must be first in swagger - tools middleware chain + // + router.use(middleware.swaggerMetadata()); + + /* + * Rate Limiting + */ + router.use(limiter); + + // + // Middleware to enforce the security rules definedin the Swagger file. + // Ignore lack of camel case for the swagger defines: + // jshint -W106 + router.use(middleware.swaggerSecurity({ + bearer: security.bearer + })); + + // + // Middleware to validate Swagger request and response parameters + // + router.use(middleware.swaggerValidator(swaggerValidatorOptions)); + + // + // Middleware to route validated requests to the appropriate controller + // + router.use(middleware.swaggerRouter(swaggerRouterOptions)); + + // + // Middleware to serve the Swagger documents and Swagger UI. + // This provides access to the Swagger UI at /dev/docs and the full + // swagger json file at /dev/api-docs + // Note: only enabled in development environments + // + if (config.isDevEnv) { + // + // NOTE: This needs the unmodified version of the swagger docs to + // *CALL* the apis at the correct, full, path (as opposed to + // the rest of the middleware which *handles* the calls in + // code running behind a router that strips part of the path. + // As it needs the original doc, we have to initialise the + // middleware ourselves rather than use the built-in initialisation + // that uses the internal, stripped, swagger docs. + // + router.use(swaggerUi(swaggerDoc, {url: '/dev/api-docs'})); + } + + // + // Error handler middleware to correct server errors as JSON if needed + // + router.use(errorHandler.errorHandlerMiddleware); + + // + // Stop any requests that didn't get handled above going any further. + // This only applies to requests under this router, so no other part of + // server could handle it. + // + router.use((req, res) => { + res.status(404).json({ + code: 30000, + description: 'API path not found' + }); + }); + }); + + return router; +} diff --git a/node_server/dev_api/security.js b/node_server/dev_api/security.js new file mode 100644 index 0000000..e983ff3 --- /dev/null +++ b/node_server/dev_api/security.js @@ -0,0 +1,118 @@ +/** + * @fileOverview Security handler functions for the dev API + */ +'use strict'; + +const debug = require('debug')('dev-api:security'); +const hashingUtils = require('../utils/hashing.js'); +const utils = require('../ComServe/utils.js'); + +const config = require(global.configFile); + +module.exports = { + bearer +}; + +const tokens = [ + 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1', + 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU2', + 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU3' +]; + +/** + * While we don't have any more details to use for a salt, just use a fixed one in this file. + * This should be replaced by a unique, per-user, salt once we have users. + */ +const NON_UNIQUE_SALT = 'fc3a82ff-5bd5-43a3-abe1-4d4623903af5'; + +/** + * Handler for the `bearer` security type. It checks the bearer token is valid. + * + * @param {Object} req - the express request + * @param {Object} def - the swagger security definition + * @param {string} scopes - the value of the Authorization header + * @param {function(error, v)} callback - Result callback + */ +function bearer(req, def, scopes, callback) { + debug('bridgeSession credentials verification'); + + // + // Check that there exists at least some value for the Authorization header + // + if (!scopes || scopes.indexOf('Bearer ') !== 0) { + debug('- no credentials supplied'); + + // + // No token, or unsupported authentication method (i.e. not 'Bearer ') + // so report an error with no further error information(per RFC6750 #3.1) + // + reportError(callback); + return; + } + + // + // Validate the token - currently a trivial comparison against a known string + // + const token = scopes.substr(7); // Remove the `Bearer ` from the front + if (tokens.includes(token)) { + // + // Make a pseudo-UserId out of our token. We do this + // by hashing the token (with a fixed salt for now), then cropping + // the result to our token length. We crop from the end of the string + // to avoid the :: at the start + // + // We then store it in req.session.data.user which is where the morgan + // logging looks for it. + // + hashingUtils.regenerateHash( + Number(config.passwordCryptoVersion), + token, + NON_UNIQUE_SALT + ).then((hash) => { + const pseudoUserID = hash.slice(-1 * utils.userIdLength); + req.session = { + data: { + user: pseudoUserID + } + }; + return callback(); + }).catch(() => { + // + // Some error in generating the hash. Just use the default error + // + return reportError(callback); + }); + } else { + /** + * Bearer auth, but token is wrong, so report an error including 'invalid_token' per RFC6750 + */ + reportError(callback, 'invalid_token'); + } +} + +/** + * Function to return a consistent error response for failures to authenticate. + * This function also builds a WWW-Authenticate header with an optional specified + * error (per RFC6750 section 3.1). + * + * @param {function(error, v)} callback - The callback to use for responses + * @param {string} explicitError - Explicit error type for the response + */ +function reportError(callback, explicitError) { + const error = new Error('Not authorised'); + error.statusCode = 401; + + if (explicitError) { + error.headers = { + 'WWW-Authenticate': ['Bearer realm="all"', 'error="' + explicitError + '"'] + }; + error.code = explicitError; + } else { + error.headers = { + 'WWW-Authenticate': 'Bearer realm="all"' + }; + error.code = 'security_error'; + } + + callback(error); +} diff --git a/node_server/dev_api/specs/catch-all-path.e2e.spec.js b/node_server/dev_api/specs/catch-all-path.e2e.spec.js new file mode 100644 index 0000000..0a081ea --- /dev/null +++ b/node_server/dev_api/specs/catch-all-path.e2e.spec.js @@ -0,0 +1,69 @@ +/** + * @fileOverview End-to-end testing of the catch-all rejection of paths not covered by swagger + */ +'use strict'; +/* eslint-disable max-nested-callbacks */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const request = require('supertest'); +const express = require('express'); +const chai = require('chai'); +const initDevApi = require('../dev_server.js'); + +const expect = chai.expect; + +/** + * Correct auth method (Bearer), correct token + */ +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +/** + * Use supertest to make an authenticated request to the server to a path + * that doesn't exist + * + * @param {Object} app - The express app to make the request to + * + * @returns {Promise} - Promise for the result of making the request + */ +function makeAuthenticatedRequest(app) { + return request(app) + .get('/dev/v0/not-a-specified-path-in-the-swagger') + .set('Accept', 'application/json') + .set('Authorization', HEADER_VALID); +} + +/** + * Tests + */ +describe('E2E: invalid path handling', () => { + describe('invalid path', () => { + let app; + + /** + * Initialise the app before running any tests + */ + before(() => { + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + it('responds with 404', () => { + return makeAuthenticatedRequest(app) + .expect(404); + }); + + it('responds with error body', () => { + return makeAuthenticatedRequest(app) + .expect(404) + .then((response) => { + return expect(response.body).to.deep.equal({ + code: 30000, + description: 'API path not found' + }); + }); + }); + }); +}); diff --git a/node_server/dev_api/specs/pay-to-stored-worldpay-account.e2e.spec.js b/node_server/dev_api/specs/pay-to-stored-worldpay-account.e2e.spec.js new file mode 100644 index 0000000..f6918a3 --- /dev/null +++ b/node_server/dev_api/specs/pay-to-stored-worldpay-account.e2e.spec.js @@ -0,0 +1,553 @@ +/** + * @fileOverview End-to-end testing of the swagger API endpoint to pay to a saved worldpay merchant. + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const request = require('supertest'); +const express = require('express'); +const _ = require('lodash'); + +const initDevApi = require('../dev_server.js'); + +function respondsWithValue(thisApp, instrumentID, params, header, value) { + return request(thisApp) + .post('/dev/v0/payment-instruments/worldpay-merchants/' + instrumentID + '/payments') + .set('Accept', 'application/json') + .set('Authorization', header) + .send(params) + .expect(value); +} + +/** + * Test values + */ + +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +const INSTRUMENT_ID = '0123456789abcdefghijklmn'; + +// Standard errored values +const TOO_LONG = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '012345678901234567890123456789012345678901234567890123456789'; +const BAD_DATE_FMT = '12?18'; +const BAD_DATE_MON = '34-18'; +const BAD_EMAIL = 'a@b@example.com'; +const BAD_PANA = '123A 1234 1234 1234'; +const BAD_PANB = '147100001111222 '; +const BAD_CVV = '12C'; +const BAD_POSTCODE = 'AB12 &CD'; +const BAD_PHONENUM = '012345G 67890'; +const BAD_ENCRYPTION_KEY = '00112233-0000-1111-54321-990000123456 00112233-0000-1111-54321-990000123456'; +const BAD_AMOUNT = 'ABC'; +const BAD_INSTRUMENT_ID = '0123456789'; + +// Valid test data +const correctParameters = { + paymentInstrument: { + payer: { + email: 'john.doe@example.com', + firstName: 'John', + lastName: 'Doe' + }, + card: { + nameOnCard: 'John E Doe', + PAN: '4444 3333 2222 1111', + expiryDate: '11-22', + startDate: '11-20', + issueNumber: 1, + CV2: '012', + address: { + address1: 'First line of address', + address2: 'Second line of address', + address3: 'Third line of addresst', + town: 'Christchurch', + county: 'Dorset', + postcode: 'BH23 6AA', + phoneNumber: '+44 123 1110000' + } + } + }, + receiveInstrument: { + encryptionKey: '00112233-0000-1111-54321-990000123456' + }, + amount: { + value: 100 + }, + transactionDetails: { + worldpay: { + orderDescription: '2 Calling Birds, 1 Partridge in a Pear tree' + } + } +}; +let badParameters; + +describe('E2E: dev api Worldpay payment for saved merchant request', () => { + let app; + + /** + * Load the dev API router to handle `/dev/*` routes + */ + before(() => { + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + describe('tests with missing required parameters', () => { + /* + * Tests where required top level attributes are missing. + * ====================================================== + * + * No paymentInstrument object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument; + + it('with no paymentInstrument parameters', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No payee receiveInstrument + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.receiveInstrument; + + it('with no receiveInstrument parameters', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No amount + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.amount; + + it('with no amount parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No transactionDetails + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails; + + it('with no transactionDetails parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Missing child elements + */ + + /* + * No paymentInstrument.payer object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer; + + it('with no paymentInstrument.payer parameters', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.payer.email + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer.email; + + it('with no paymentInstrument.payer.email parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.payer.firstName + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer.firstName; + + it('with no paymentInstrument.payer.firstName parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.payer.lastName + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer.lastName; + + it('with no paymentInstrument.payer.lastName parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card; + + it('with no paymentInstrument.card parameters', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.nameOnCard + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.nameOnCard; + + it('with no paymentInstrument.card.nameOnCard parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.PAN + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.PAN; + + it('with no paymentInstrument.card.PAN parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.expiryDate + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.expiryDate; + + it('with no paymentInstrument.card.expiryDate parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.CV2 + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.CV2; + + it('with no paymentInstrument.card.CV2 parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address; + + it('with no paymentInstrument.card.address parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address.address1 + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address.address1; + + it('with no paymentInstrument.card.address.address1 parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address.town + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address.town; + + it('with no paymentInstrument.card.address.town parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address.postcode + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address.postcode; + + it('with no paymentInstrument.card.address.postcode parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No transactionDetails.worldpay object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails.worldpay; + + it('with no transactionDetails.worldpay parameters', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No receiving account encryption key + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.receiveInstrument.encryptionKey; + + it('with no receiving account encryption key', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + }); + + describe('bad data format tests', () => { + /* + * Invalid data format errors + * ========================== + * + * Bad paymentInstrument.payer.email + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.payer.email = BAD_EMAIL; + + it('with an invalid paymentInstrument email parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.payer.firstName + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.payer.firstName = 'Axel Rose'; + + it('with an invalid payer first name parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.payer.lastName + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.payer.lastName = 'Axel Rose'; + + it('with an invalid payer last name parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.PAN + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.PAN = BAD_PANA; + + it('with a bad card PAN parameter containing a letter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.PAN = BAD_PANB; + + it('with a bad card PAN parameter with a trailing space', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.expiryDate 1 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.expiryDate = BAD_DATE_FMT; + + it('with a bad character in the payment card expiry date parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.expiryDate 2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.expiryDate = BAD_DATE_MON; + + it('with a bad month number in the payment card expiry date parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.startDate + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.startDate = BAD_DATE_FMT; + + it('with a badly formatted payment card start date parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.startDate + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.startDate = BAD_DATE_MON; + + it('with a bad month number in the payment card start date parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.issueNumber + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.issueNumber = 'Z'; + + it('with a badly formatted payment card issue number parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.CV2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.CV2 = BAD_CVV; + + it('with a badly formatted payment card CV2 parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address1: too long + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address1 = TOO_LONG; + + it('with a card address line 1 too long', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address1: too short + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address1 = ''; + + it('with a card address line 1 too short', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address2 = TOO_LONG; + + it('with a badly formatted card address line 2 parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address3 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address3 = TOO_LONG; + + it('with a badly formatted card address line 3 parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.town + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.town = TOO_LONG; + + it('with a badly formatted card address town name parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.county + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.county = TOO_LONG; + + it('with a badly formatted card address county name parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.postcode + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.postcode = BAD_POSTCODE; + + it('with a badly formatted card address postcode parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.phoneNumber + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.phoneNumber = BAD_PHONENUM; + + it('with a badly formatted card address phone number parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad orderDescription + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.transactionDetails.worldpay.orderDescription = TOO_LONG; + + it('with a badly formatted order description parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * ) Bad receiving account decryption key + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.receiveInstrument.encryptionKey = BAD_ENCRYPTION_KEY; + + it('with a badly formatted receiving account encryption key parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * ) Bad amount.value + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.amount.value = BAD_AMOUNT; + + it('with a badly formatted amount value parameter', () => { + return respondsWithValue(app, INSTRUMENT_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad instrument ID. + */ + it('with a badly formatted instrument ID', () => { + return respondsWithValue(app, BAD_INSTRUMENT_ID, correctParameters, HEADER_VALID, 400); + }); + }); + + /* Skip these tests for two reasons: the return will change and they fail. */ + describe.skip('Good parameter data tests', () => { + /* + * Verify that the command works with the minimum set of correctly formatted parameters. + */ + const minimumValidSet = _.cloneDeep(correctParameters); + delete minimumValidSet.paymentInstrument.card.startDate; + delete minimumValidSet.paymentInstrument.card.issueNumber; + delete minimumValidSet.paymentInstrument.card.address.address2; + delete minimumValidSet.paymentInstrument.card.address.address3; + delete minimumValidSet.paymentInstrument.card.address.county; + delete minimumValidSet.paymentInstrument.card.address.phoneNumber; + it('with the minimum set of correct parameters', () => { + return respondsWithValue(app, INSTRUMENT_ID, minimumValidSet, HEADER_VALID, 400); + }); + + /* + * Verify that the command works with correctly formatted full set of parameters. + */ + it('with a full set of correct parameters', () => { + return respondsWithValue(app, INSTRUMENT_ID, correctParameters, HEADER_VALID, 400); + }); + }); +}); diff --git a/node_server/dev_api/specs/pay_with_payment_instrument.e2e.spec.js b/node_server/dev_api/specs/pay_with_payment_instrument.e2e.spec.js new file mode 100644 index 0000000..3c3115f --- /dev/null +++ b/node_server/dev_api/specs/pay_with_payment_instrument.e2e.spec.js @@ -0,0 +1,263 @@ +/** + * @fileOverview End-to-end testing of the payment instruments add card swagger API + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const request = require('supertest'); +const express = require('express'); +const _ = require('lodash'); + +const initDevApi = require('../dev_server.js'); + +function respondsWithValue(thisApp, cardID, params, header, value) { + return request(thisApp) + .post('/dev/v0/payment-instruments/cards/' + cardID + '/payments') + .set('Accept', 'application/json') + .set('Authorization', header) + .send(params) + .expect(value); +} + +/** + * Test values + */ + +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +const VALID_CARD_ID = '12345678-0000-0000-0000-123456789012'; +const BAD_CARD_ID = '-99-'; +const BAD_CVV = 'ABC'; + +// Standard errored values +const TOO_LONG = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '012345678901234567890123456789012345678901234567890123456789'; +const BAD_AMOUNT_VALUE = 'AB12 &CD'; + +// Valid test data +const correctParameters = { + paymentInstrument: { + encryptionKey: 'f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1f0a1', + CV2: '123' + }, + payee: { + worldpay: { + receivingAccountServiceKey: 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57' + } + }, + amount: { + value: 1000 + }, + transactionDetails: { + worldpay: { + orderDescription: '2 Calling Birds, 1 Partridge in a Pear tree' + } + } +}; +let badParameters; + +describe('E2E: pay with saved card request', () => { + let app; + + /** + * Load the dev API router to handle `/dev/*` routes + */ + before(() => { + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + describe('tests with missing required parameters', () => { + /* + * Tests where required top level attributes are missing. + * ====================================================== + */ + + /* + * No paymentInstrument + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument; + + it('with no paymentInstrument parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No payee + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payee; + + it('with no cloneDeep parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No amount + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.amount; + + it('with no amount parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No transactionDetails + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails; + + it('with no transactionDetails parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Tests where required child attributes are missing. + * ================================================== + */ + + /* + * No paymentInstrument.encryptionKey + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.encryptionKey; + + it('with no paymentInstrument.encryptionKey parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No payee.worldpay + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payee.worldpay; + + it('with no payee.worldpay parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No payee.worldpay.receivingAccountServiceKey + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payee.worldpay.receivingAccountServiceKey; + + it('with no payee.worldpay.receivingAccountServiceKey parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No amount.value + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.amount.value; + + it('with no amount.value parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No transactionDetails.worldpay + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails.worldpay; + + it('with no transactionDetails.worldpay parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * No transactionDetails.worldpay.orderDescription + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails.worldpay.orderDescription; + + it('with no transactionDetails.worldpay.orderDescription parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + }); + + describe('bad data format tests', () => { + /* + * Invalid data format errors + * ========================== + */ + + /* + * Bad card ID + */ + badParameters = _.cloneDeep(correctParameters); + + it('with a badly formatted card ID path parameter', () => { + return respondsWithValue(app, BAD_CARD_ID, correctParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.encryptionKey + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.encryptionKey = TOO_LONG; + + it('with a badly formatted paymentInstrument.encryptionKey parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.CV2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.CV2 = BAD_CVV; + + it('with a badly formatted paymentInstrument.CV2 parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad payee.worldpay.receivingAccountServiceKey + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.payee.worldpay.receivingAccountServiceKey = TOO_LONG; + + it('with a badly formatted payee.worldpay.receivingAccountServiceKey parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad payee.worldpay.receivingAccountServiceKey + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.amount.value = BAD_AMOUNT_VALUE; + + it('with a badly formatted amount.value parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad transactionDetails.worldpay.orderDescription + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.transactionDetails.worldpay.orderDescription = TOO_LONG; + + it('with a badly formatted transactionDetails.worldpay.orderDescription parameter', () => { + return respondsWithValue(app, VALID_CARD_ID, badParameters, HEADER_VALID, 400); + }); + }); + + /* Skip these tests as they will cahnge when the actual function is implemented. */ + describe.skip('Good parameter data tests', () => { + /* + * Verify that the command works with correctly formatted parameters. + */ + it('with a full set of correct parameters', () => { + return respondsWithValue(app, VALID_CARD_ID, correctParameters, HEADER_VALID, 500); + }); + }); +}); diff --git a/node_server/dev_api/specs/payment_instruments_add_card.e2e.spec.js b/node_server/dev_api/specs/payment_instruments_add_card.e2e.spec.js new file mode 100644 index 0000000..069559b --- /dev/null +++ b/node_server/dev_api/specs/payment_instruments_add_card.e2e.spec.js @@ -0,0 +1,445 @@ +/** + * @fileOverview End-to-end testing of the payment instruments add card swagger API + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const request = require('supertest'); +const express = require('express'); +const _ = require('lodash'); + +const initDevApi = require('../dev_server.js'); + +function respondsWithValue(thisApp, params, header, value) { + return request(thisApp) + .post('/dev/v0/payment-instruments/cards') + .set('Accept', 'application/json') + .set('Authorization', header) + .send(params) + .expect(value); +} + +/** + * Test values + */ + +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +// Standard errored values +const TOO_LONG = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '012345678901234567890123456789012345678901234567890123456789'; +const BAD_DATE_FMT = '12?18'; +const BAD_DATE_MON = '34-18'; +const BAD_PANA = '123A 1234 1234 1234'; +const BAD_PANB = '147100001111222 '; +const BAD_POSTCODE = 'AB12 &CD'; +const BAD_PHONENUM = '012345G 67890'; +const BAD_NAME = '^£&$£%&$'; +const BAD_EMAIL = 'a@b@c.com'; + +// Valid test data +const correctParameters = { + payer: { + email: 'peon@example.com', + firstName: 'John', + lastName: 'Doe' + }, + description: 'A random bank card.', + card: { + nameOnCard: 'John Doe', + PAN: '4444 3333 2222 1111', + expiryDate: '11-22', + startDate: '11-20', + issueNumber: 1, + CV2: '012', + address: { + address1: 'First line of address', + address2: 'Second line of address', + address3: 'Third line of addresst', + town: 'Christchurch', + county: 'Dorset', + postcode: 'BH23 6AA', + phoneNumber: '+44 123 1110000' + } + } +}; +let badParameters; + +describe('E2E: save card for future use request', () => { + let app; + + /** + * Load the dev API router to handle `/dev/*` routes + */ + before(() => { + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + describe('tests with missing required parameters', () => { + /* + * Tests where required top level attributes are missing. + * ====================================================== + */ + + /* + * No payer + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payer; + + it('with no payer parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No card + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card; + + it('with no card parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Tests where required child attributes are missing. + * ====================================================== + */ + + /* + * No payer.email + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payer.email; + + it('with no payer.email parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No payer.firstName + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payer.firstName; + + it('with no payer.firstName parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No payer.lastName + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payer.lastName; + + it('with no payer.lastName parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No card.PAN + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.PAN; + + it('with no card.PAN parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No expiryDate + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.expiryDate; + + it('with no card.expiryDate parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No CV2 + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.CV2; + + it('with no card.CV2 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No name on card field + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.nameOnCard; + + it('with no card.nameOnCard parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No address + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.address; + + it('with no card.address parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No card.address.address1 + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.address.address1; + + it('with no card.address.address1 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No card.address.town + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.address.town; + + it('with no card.address.town parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No card.address.postcode + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.card.address.postcode; + + it('with no card.address.postcode parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + }); + + describe('bad data format tests', () => { + /* + * Invalid data format errors + * ========================== + */ + + /* + * Bad payer first name + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.payer.firstName = BAD_NAME; + + it('with a badly formatted first name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad payer last name + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.payer.lastName = BAD_NAME; + + it('with a badly formatted last name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad payer email + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.payer.email = BAD_EMAIL; + + it('with a badly formatted email parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card description field + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.description = TOO_LONG; + + it('with a badly formatted card description parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad PAN + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.PAN = BAD_PANA; + + it('with a bad card PAN parameter containing a letter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + badParameters = _.cloneDeep(correctParameters); + badParameters.card.PAN = BAD_PANB; + + it('with a bad card PAN parameter with a trailing space', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad expiryDate 1 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.expiryDate = BAD_DATE_FMT; + + it('with a bad character in the card expiry date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad expiryDate 2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.expiryDate = BAD_DATE_MON; + + it('with a bad month number in the card expiry date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad startDate + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.startDate = BAD_DATE_FMT; + + it('with a badly formatted card start date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad startDate + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.startDate = BAD_DATE_MON; + + it('with a bad month number in the card start date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad issueNumber + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.issueNumber = 'Z'; + + it('with a badly formatted card issue number parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.address1: too long + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.address1 = TOO_LONG; + + it('with a card address line 1 too long', () => { + respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.address1: too short + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.address1 = ''; + + it('with a card address line 1 too short', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.address2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.address2 = TOO_LONG; + + it('with a badly formatted card address line 2 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.address3 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.address3 = TOO_LONG; + + it('with a badly formatted card address line 3 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.town + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.town = TOO_LONG; + + it('with a badly formatted card address town name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.county + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.county = TOO_LONG; + + it('with a badly formatted card address county name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * ) Bad card.address.postcode + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.postcode = BAD_POSTCODE; + + it('with a badly formatted card address postcode parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * ) Bad card.address.phoneNumber + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.card.address.phoneNumber = BAD_PHONENUM; + + it('with a badly formatted card address phone number parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + }); + + /* Skip these tests as they will cahnge when the actual function is implemented. */ + describe.skip('Good parameter data tests', () => { + /* + * Verify that the command correctly validates a minimum set of correctly formatted parameters. + */ + const minimumValidSet = _.cloneDeep(correctParameters); + delete minimumValidSet.description; + delete minimumValidSet.card.startDate; + delete minimumValidSet.card.issueNumber; + delete minimumValidSet.card.address.address2; + delete minimumValidSet.card.address.address3; + delete minimumValidSet.card.address.county; + delete minimumValidSet.card.address.phoneNumber; + it('with the minimum set of correct parameters', () => { + return respondsWithValue(app, minimumValidSet, HEADER_VALID, 500); + }); + + /* + * Verify that the command works with correctly formatted parameters. + */ + it('with a full set of correct parameters', () => { + return respondsWithValue(app, correctParameters, HEADER_VALID, 500); + }); + }); +}); diff --git a/node_server/dev_api/specs/rate-limiting.e2e.spec.js b/node_server/dev_api/specs/rate-limiting.e2e.spec.js new file mode 100644 index 0000000..ca087cb --- /dev/null +++ b/node_server/dev_api/specs/rate-limiting.e2e.spec.js @@ -0,0 +1,140 @@ +/** + * @fileOverview End-to-end testing of the swagger API security middleware + */ +'use strict'; +/* eslint-disable max-nested-callbacks */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const _ = require('lodash'); +const request = require('supertest'); +const express = require('express'); +const sinon = require('sinon'); +const chai = require('chai'); +const initDevApi = require('../dev_server.js'); + +const config = require(global.configFile); + +/** + * Use fake timers so we can control the timing of the rate-limit window + */ +const fakeTimer = sinon.useFakeTimers(); + +const expect = chai.expect; + +/** + * Test values + */ +const oldRateLimits = _.cloneDeep(config.rateLimits.api); +const TEST_RATE_WINDOW_MS = 500; + +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +/** + * Use supertest to make an authenticated request to the server + * + * @param {Object} app - The express app to make the request to + * + * @returns {Promise} - Promise for the result of making the request + */ +function makeAuthenticatedRequest(app) { + return request(app) + .get('/dev/v0/test') + .set('Accept', 'application/json') + .set('Authorization', HEADER_VALID); +} + +/** + * Tests + */ +describe('E2E: rate limiting test', () => { + let app; + before(() => { + // + // Change the config to reduce the rate limit limits for testing + // + config.rateLimits.api.windowMs = TEST_RATE_WINDOW_MS; + config.rateLimits.api.max = 2; + + // + // Initialise the test app + // + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + /** + * Put the old limits and real timers back after all the tests are complete + */ + after(() => { + _.merge(config.rateLimits.api, oldRateLimits); + fakeTimer.restore(); + }); + + /** + * Advance the fakeTimer after each test so we get a new rate-limit window + */ + afterEach(() => { + fakeTimer.tick(TEST_RATE_WINDOW_MS + 1); + }); + + describe('requests that don\'t exceed the limit', () => { + it('are allowed', () => { + const req1 = makeAuthenticatedRequest(app) + .expect(200); + + return req1.then(() => { + return makeAuthenticatedRequest(app) + .expect(200); + }); + }); + + it('inform the caller how many requests are left', () => { + return makeAuthenticatedRequest(app) + .expect(200) + .expect('x-ratelimit-limit', '2') + .expect('x-ratelimit-remaining', '1'); + }); + }); + + describe('requests that exceed the limit', () => { + it('respond with 429 Too Many Requests', () => { + const req1 = makeAuthenticatedRequest(app) + .expect(200); + + const req2 = req1.then(() => { + return makeAuthenticatedRequest(app) + .expect(200); + }); + + return req2.then(() => { + return makeAuthenticatedRequest(app) + .expect(429); + }); + }); + + it('return an error code and description in the body', () => { + const req1 = makeAuthenticatedRequest(app) + .expect(200); + + const req2 = req1.then(() => { + return makeAuthenticatedRequest(app) + .expect(200); + }); + + return req2.then(() => { + return makeAuthenticatedRequest(app) + .expect(429) + .then((response) => { + return expect(response.body).to.deep.equal({ + code: 30500, + description: 'Rate limit reached. Please wait and try again.' + }); + }); + }); + }); + }); +}); diff --git a/node_server/dev_api/specs/security.e2e.spec.js b/node_server/dev_api/specs/security.e2e.spec.js new file mode 100644 index 0000000..0587c64 --- /dev/null +++ b/node_server/dev_api/specs/security.e2e.spec.js @@ -0,0 +1,150 @@ +/** + * @fileOverview End-to-end testing of the swagger API security middleware + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const request = require('supertest'); +const express = require('express'); + +const initDevApi = require('../dev_server.js'); + +/** + * Test values + */ +let app; + +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +// Correct auth method, wrong token +const TOKEN_WRONG = 'thisISnotTHEcorrectTOKENforTHISrequestMATCHESlen'; +const HEADER_WRONG = 'Bearer ' + TOKEN_WRONG; + +// Wrong auth method (Basic), right token +const HEADER_BASIC_AUTH = 'Basic ' + TOKEN_VALID; + +// No auth method specified +const HEADER_MISSING_BEARER = TOKEN_VALID; + +// Correct auth method, no token +const HEADER_BEARER_ONLY = 'Bearer '; + +let requestP; // Standard name for the request promise +describe('E2E: dev api bearer security request', () => { + /** + * Load the dev API router to handle `/dev/*` routes + */ + before(() => { + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + describe('with correct authentication', () => { + it('responds with 200', () => { + return request(app) + .get('/dev/v0/test') + .set('Accept', 'application/json') + .set('Authorization', HEADER_VALID) + .expect(200); + }); + }); + + describe('with no authentication', () => { + beforeEach(() => { + requestP = request(app) + .get('/dev/v0/test') + .set('Accept', 'application/json'); + }); + + it('responds with 401', () => { + return requestP + .expect(401); + }); + + it('includes a minimal WWW-Authorisation header', () => { + return requestP + .expect('WWW-Authenticate', 'Bearer realm="all"'); + }); + }); + + describe('with unsupported authentication method (e.g Basic)', () => { + beforeEach(() => { + requestP = request(app) + .get('/dev/v0/test') + .set('Accept', 'application/json') + .set('Authorization', HEADER_BASIC_AUTH); + }); + + it('responds with 401', () => { + return requestP + .expect(401); + }); + + it('includes a minimal WWW-Authorisation header', () => { + return requestP + .expect('WWW-Authenticate', 'Bearer realm="all"'); + }); + }); + + describe('with token but no authentication method', () => { + beforeEach(() => { + requestP = request(app) + .get('/dev/v0/test') + .set('Accept', 'application/json') + .set('Authorization', HEADER_MISSING_BEARER); + }); + + it('responds with 401', () => { + return requestP + .expect(401); + }); + + it('includes a minimal WWW-Authorisation header', () => { + return requestP + .expect('WWW-Authenticate', 'Bearer realm="all"'); + }); + }); + + describe('with Bearer authentication but wrong token', () => { + beforeEach(() => { + requestP = request(app) + .get('/dev/v0/test') + .set('Accept', 'application/json') + .set('Authorization', HEADER_WRONG); + }); + + it('responds with 401', () => { + return requestP + .expect(401); + }); + + it('includes a WWW-Authorisation header with error="invalid_token"', () => { + return requestP + .expect('WWW-Authenticate', 'Bearer realm="all", error="invalid_token"'); + }); + }); + + describe('with Bearer authentication but zero-length token', () => { + beforeEach(() => { + requestP = request(app) + .get('/dev/v0/test') + .set('Accept', 'application/json') + .set('Authorization', HEADER_BEARER_ONLY); + }); + + it('responds with 401', () => { + return requestP + .expect(401); + }); + + it('includes a WWW-Authorisation header with error="invalid_token"', () => { + return requestP + .expect('WWW-Authenticate', 'Bearer realm="all", error="invalid_token"'); + }); + }); +}); diff --git a/node_server/dev_api/specs/security.spec.js b/node_server/dev_api/specs/security.spec.js new file mode 100644 index 0000000..2ffef12 --- /dev/null +++ b/node_server/dev_api/specs/security.spec.js @@ -0,0 +1,142 @@ +/** + * Unit testing file for ElevateSession command + */ +'use strict'; +const util = require('util'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +const security = require('../security.js'); + +/** + * Set up chai & sinon to simplify the tests + */ +const expect = chai.expect; +const sandbox = sinon.createSandbox(); + +chai.use(sinonChai); + +/** + * Test values + */ +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +// Correct auth method, wrong token +const TOKEN_WRONG = 'thisISnotTHEcorrectTOKENforTHISrequestMATCHESlen'; +const HEADER_WRONG = 'Bearer ' + TOKEN_WRONG; + +// Wrong auth method (Basic), right token +const HEADER_BASIC_AUTH = 'Basic ' + TOKEN_VALID; + +// No auth method specified +const HEADER_MISSING_BEARER = TOKEN_VALID; + +// Correct auth method, no token +const HEADER_BEARER_ONLY = 'Bearer '; + +const REQ = {}; // Not used +const DEF = {}; // No used + +/** + * The tests + */ +describe('dev API bearer security', () => { + const callbackSpy = sandbox.spy(); + + afterEach(() => { + sandbox.resetHistory(); + }); + + it('accepts a valid bearer token', () => { + return util.promisify(security.bearer)(REQ, DEF, HEADER_VALID) + .catch((error) => expect(error).to.be.undefined); // If catch is called, this has failed + }); + + it('rejects incorrect bearer token with 401 and WWW-Authenticate header with "invalid_token"', () => { + security.bearer(REQ, DEF, HEADER_WRONG, callbackSpy); + expect(callbackSpy).to.have.been + .calledOnce + .calledWith( + sinon.match + .instanceOf(Error) + .and(sinon.match.has('statusCode', 401)) + .and(sinon.match.has( + 'headers', + sinon.match({ + 'WWW-Authenticate': ['Bearer realm="all"', 'error="invalid_token"'] + }) + )) + ); + }); + + it('rejects zero-length Bearer token with 401 and WWW-Authenticate header with "invalid_token"', () => { + security.bearer(REQ, DEF, HEADER_BEARER_ONLY, callbackSpy); + expect(callbackSpy).to.have.been + .calledOnce + .calledWith( + sinon.match + .instanceOf(Error) + .and(sinon.match.has('statusCode', 401)) + .and(sinon.match.has( + 'headers', + sinon.match({ + 'WWW-Authenticate': ['Bearer realm="all"', 'error="invalid_token"'] + }) + )) + ); + }); + + it('rejects invalid auth method with 401 and minimal WWW-Authenticate header', () => { + security.bearer(REQ, DEF, HEADER_BASIC_AUTH, callbackSpy); + expect(callbackSpy).to.have.been + .calledOnce + .calledWith( + sinon.match + .instanceOf(Error) + .and(sinon.match.has('statusCode', 401)) + .and(sinon.match.has( + 'headers', + { + 'WWW-Authenticate': 'Bearer realm="all"' + } + )) + ); + }); + + it('rejects missing auth method with 401 and minimal WWW-Authenticate header', () => { + security.bearer(REQ, DEF, HEADER_MISSING_BEARER, callbackSpy); + expect(callbackSpy).to.have.been + .calledOnce + .calledWith( + sinon.match + .instanceOf(Error) + .and(sinon.match.has('statusCode', 401)) + .and(sinon.match.has( + 'headers', + { + 'WWW-Authenticate': 'Bearer realm="all"' + } + )) + ); + }); + + it('rejects no auth at all with 401 and minimal WWW-Authenticate header', () => { + security.bearer(REQ, DEF, undefined, callbackSpy); + expect(callbackSpy).to.have.been + .calledOnce + .calledWith( + sinon.match + .instanceOf(Error) + .and(sinon.match.has('statusCode', 401)) + .and(sinon.match.has( + 'headers', + { + 'WWW-Authenticate': 'Bearer realm="all"' + } + )) + ); + }); +}); diff --git a/node_server/dev_api/specs/store-worldpay-merchant.e2e.spec.js b/node_server/dev_api/specs/store-worldpay-merchant.e2e.spec.js new file mode 100644 index 0000000..b6d55a5 --- /dev/null +++ b/node_server/dev_api/specs/store-worldpay-merchant.e2e.spec.js @@ -0,0 +1,120 @@ +/** + * @fileOverview End-to-end testing of the payment instruments save receiving worldpay swagger API + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const request = require('supertest'); +const express = require('express'); +const _ = require('lodash'); + +const initDevApi = require('../dev_server.js'); + +function respondsWithValue(thisApp, params, header, value) { + return request(thisApp) + .post('/dev/v0/payment-instruments/worldpay-merchants') + .set('Accept', 'application/json') + .set('Authorization', header) + .send(params) + .expect(value); +} + +/** + * Test values + */ + +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +// Standard errored values +const TOO_LONG = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '012345678901234567890123456789012345678901234567890123456789'; +const BAD_MERCHANT_SERVICE_KEY = TOO_LONG; +const BAD_DESCRIPTION = TOO_LONG; + +// Valid test data +const correctParameters = { + description: 'Bloggs Co Inc', + receivingAccountServiceKey: 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57' +}; +let badParameters; + +describe('E2E: save worldpay merchant account for future use request', () => { + let app; + + /** + * Load the dev API router to handle `/dev/*` routes + */ + before(() => { + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + describe('test with missing required parameters', () => { + /* + * Tests where required top level attributes are missing. + * ====================================================== + */ + + /* + * No receiving account service key + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.receivingAccountServiceKey; + + it('with no receivingAccountServiceKey parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + }); + + describe('bad data format tests', () => { + /* + * Invalid data format errors + * ========================== + */ + + /* + * Bad receiving acount service key + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.receivingAccountServiceKey = BAD_MERCHANT_SERVICE_KEY; + + it('with a badly formatted receivingAccountServiceKey parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad description + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.description = BAD_DESCRIPTION; + + it('with a badly formatted description parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + }); + + /* Skip these tests as they will change when the actual function is implemented. */ + describe.skip('Good parameter data tests', () => { + /* + * Verify that the command correctly validates a minimum set of correctly formatted parameters. + */ + const minimumValidSet = _.cloneDeep(correctParameters); + delete minimumValidSet.description; + it('with the minimum set of correct parameters', () => { + return respondsWithValue(app, minimumValidSet, HEADER_VALID, 500); + }); + + /* + * Verify that the command works with correctly formatted parameters. + */ + it('with a full set of correct parameters', () => { + return respondsWithValue(app, correctParameters, HEADER_VALID, 500); + }); + }); +}); diff --git a/node_server/dev_api/specs/worldpay_transaction.e2e.spec.js b/node_server/dev_api/specs/worldpay_transaction.e2e.spec.js new file mode 100644 index 0000000..4c7194e --- /dev/null +++ b/node_server/dev_api/specs/worldpay_transaction.e2e.spec.js @@ -0,0 +1,545 @@ +/** + * @fileOverview End-to-end testing of the swagger API security middleware + */ +'use strict'; + +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); + +const request = require('supertest'); +const express = require('express'); +const _ = require('lodash'); + +const initDevApi = require('../dev_server.js'); + +function respondsWithValue(thisApp, params, header, value) { + return request(thisApp) + .post('/dev/v0/payments/worldpay') + .set('Accept', 'application/json') + .set('Authorization', header) + .send(params) + .expect(value); +} + +/** + * Test values + */ + +// Correct auth method (Bearer), correct token +const TOKEN_VALID = 'YTM2ZGQ1NzUtOWFmNS01MjMyLTg5MjYtM2NkZjA5ZDU2ZGU1'; +const HEADER_VALID = 'Bearer ' + TOKEN_VALID; + +// Standard errored values +const TOO_LONG = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' + + '012345678901234567890123456789012345678901234567890123456789'; +const BAD_DATE_FMT = '12?18'; +const BAD_DATE_MON = '34-18'; +const BAD_EMAIL = 'a@b@example.com'; +const BAD_PANA = '123A 1234 1234 1234'; +const BAD_PANB = '147100001111222 '; +const BAD_CVV = '12C'; +const BAD_POSTCODE = 'AB12 &CD'; +const BAD_PHONENUM = '012345G 67890'; +const BAD_SERVICEKEY = 'A_A_4db79f58-b8e8-4485-9346-1aafe16ffc57'; +const BAD_AMOUNT = 'ABC'; + +// Valid test data +const correctParameters = { + paymentInstrument: { + payer: { + email: 'john.doe@example.com', + firstName: 'John', + lastName: 'Doe' + }, + card: { + nameOnCard: 'John E Doe', + PAN: '4444 3333 2222 1111', + expiryDate: '11-22', + startDate: '11-20', + issueNumber: 1, + CV2: '012', + address: { + address1: 'First line of address', + address2: 'Second line of address', + address3: 'Third line of addresst', + town: 'Christchurch', + county: 'Dorset', + postcode: 'BH23 6AA', + phoneNumber: '+44 123 1110000' + } + } + }, + payee: { + worldpay: { + receivingAccountServiceKey: 'T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57' + } + }, + amount: { + value: 100 + }, + transactionDetails: { + worldpay: { + orderDescription: '2 Calling Birds, 1 Partridge in a Pear tree' + } + } +}; +let badParameters; + +describe('E2E: dev api Worldpay payment request', () => { + let app; + + /** + * Load the dev API router to handle `/dev/*` routes + */ + before(() => { + app = express(); + const devApiRouter = initDevApi.init(); + app.use('/dev', devApiRouter); + }); + + describe('tests with missing required parameters', () => { + /* + * Tests where required top level attributes are missing. + * ====================================================== + * + * No paymentInstrument object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument; + + it('with no paymentInstrument parameters', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No payee object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.payee; + + it('with no payee parameters', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No amount + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.amount; + + it('with no amount parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No transactionDetails + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails; + + it('with no transactionDetails parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Missing child elements + */ + + /* + * No paymentInstrument.payer object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer; + + it('with no paymentInstrument.payer parameters', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.payer.email + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer.email; + + it('with no paymentInstrument.payer.email parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.payer.firstName + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer.firstName; + + it('with no paymentInstrument.payer.firstName parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.payer.lastName + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.payer.lastName; + + it('with no paymentInstrument.payer.lastName parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card; + + it('with no paymentInstrument.card parameters', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.nameOnCard + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.nameOnCard; + + it('with no paymentInstrument.card.nameOnCard parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.PAN + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.PAN; + + it('with no paymentInstrument.card.PAN parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.expiryDate + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.expiryDate; + + it('with no paymentInstrument.card.expiryDate parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.CV2 + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.CV2; + + it('with no paymentInstrument.card.CV2 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address; + + it('with no paymentInstrument.card.address parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address.address1 + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address.address1; + + it('with no paymentInstrument.card.address.address1 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address.town + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address.town; + + it('with no paymentInstrument.card.address.town parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No paymentInstrument.card.address.postcode + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.paymentInstrument.card.address.postcode; + + it('with no paymentInstrument.card.address.postcode parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No transactionDetails.worldpay object + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails.worldpay; + + it('with no transactionDetails.worldpay parameters', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * No receiving account service key + */ + badParameters = _.cloneDeep(correctParameters); + delete badParameters.transactionDetails.worldpay.receivingAccountServiceKey; + + it('with no transactionDetails.worldpay.receivingAccountServiceKey parameters', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + }); + + describe('bad data format tests', () => { + /* + * Invalid data format errors + * ========================== + * + * Bad paymentInstrument.payer.email + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.payer.email = BAD_EMAIL; + + it('with an invalid paymentInstrument email parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.payer.firstName + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.payer.firstName = 'Axel Rose'; + + it('with an invalid payer first name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.payer.lastName + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.payer.lastName = 'Axel Rose'; + + it('with an invalid payer last name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.PAN + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.PAN = BAD_PANA; + + it('with a bad card PAN parameter containing a letter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.PAN = BAD_PANB; + + it('with a bad card PAN parameter with a trailing space', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.expiryDate 1 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.expiryDate = BAD_DATE_FMT; + + it('with a bad character in the payment card expiry date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.expiryDate 2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.expiryDate = BAD_DATE_MON; + + it('with a bad month number in the payment card expiry date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.startDate + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.startDate = BAD_DATE_FMT; + + it('with a badly formatted payment card start date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.startDate + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.startDate = BAD_DATE_MON; + + it('with a bad month number in the payment card start date parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.issueNumber + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.issueNumber = 'Z'; + + it('with a badly formatted payment card issue number parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.CV2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.CV2 = BAD_CVV; + + it('with a badly formatted payment card CV2 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address1: too long + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address1 = TOO_LONG; + + it('with a card address line 1 too long', () => { + respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address1: too short + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address1 = ''; + + it('with a card address line 1 too short', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address2 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address2 = TOO_LONG; + + it('with a badly formatted card address line 2 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.address3 + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.address3 = TOO_LONG; + + it('with a badly formatted card address line 3 parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.town + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.town = TOO_LONG; + + it('with a badly formatted card address town name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad paymentInstrument.card.address.county + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.county = TOO_LONG; + + it('with a badly formatted card address county name parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.postcode + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.postcode = BAD_POSTCODE; + + it('with a badly formatted card address postcode parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad card.address.phoneNumber + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.paymentInstrument.card.address.phoneNumber = BAD_PHONENUM; + + it('with a badly formatted card address phone number parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * Bad orderDescription + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.transactionDetails.worldpay.orderDescription = TOO_LONG; + + it('with a badly formatted order description parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * ) Bad receivingAccountServiceKey + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.payee.worldpay.receivingAccountServiceKey = BAD_SERVICEKEY; + + it('with a badly formatted receiving account service key parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + + /* + * ) Bad amount.value + */ + badParameters = _.cloneDeep(correctParameters); + badParameters.amount.value = BAD_AMOUNT; + + it('with a badly formatted amount value parameter', () => { + return respondsWithValue(app, badParameters, HEADER_VALID, 400); + }); + }); + + /* Skip these tests for two reasons: the return will change and they fail. */ + describe.skip('Good parameter data tests', () => { + /* + * Verify that the command works with the minimum set of correctly formatted parameters. + */ + const minimumValidSet = _.cloneDeep(correctParameters); + delete minimumValidSet.paymentInstrument.card.startDate; + delete minimumValidSet.paymentInstrument.card.issueNumber; + delete minimumValidSet.paymentInstrument.card.address.address2; + delete minimumValidSet.paymentInstrument.card.address.address3; + delete minimumValidSet.paymentInstrument.card.address.county; + delete minimumValidSet.paymentInstrument.card.address.phoneNumber; + it('with the minimum set of correct parameters', () => { + return respondsWithValue(app, minimumValidSet, HEADER_VALID, 400); + }); + + /* + * Verify that the command works with correctly formatted parameters. + */ + it('with a full set of correct parameters', () => { + return respondsWithValue(app, correctParameters, HEADER_VALID, 400); + }); + }); +}); diff --git a/node_server/dev_api/uniqueIdMiddleware.js b/node_server/dev_api/uniqueIdMiddleware.js new file mode 100644 index 0000000..df1c7d6 --- /dev/null +++ b/node_server/dev_api/uniqueIdMiddleware.js @@ -0,0 +1,21 @@ +/** + * @fileOverview Add a unique id to every request so we can correlate logs + */ +'use strict'; + +const uuidv4 = require('uuid/v4'); + +module.exports = uniqueIdMiddleware; + +/** + * Middleware function to add a unique ID (random UUID v4) to each request. This + * can then be added to the logging to correlate logs from the same call. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Object} next - Callback to continue processing + */ +function uniqueIdMiddleware(req, res, next) { + req.bridgeUniqueId = uuidv4(); + next(); +} diff --git a/node_server/exitcodes.js b/node_server/exitcodes.js new file mode 100644 index 0000000..51a2f49 --- /dev/null +++ b/node_server/exitcodes.js @@ -0,0 +1,22 @@ +/** + * @fileOverview Node.js Exit Codes for Main Process + * @preserve Copyright 2014-2017 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ + +/** + * Error constants. + */ +exports.EXIT_CODE_SUCCESS = 0; +exports.EXIT_CODE_DATABASE_NOT_CLOSED = 1; +exports.EXIT_CODE_DATABASE_WRITE_ERROR = 2; +exports.EXIT_CODE_DATABASE_OFFLINE = 3; +exports.EXIT_CODE_FORCED_SHUTDOWN = 4; +exports.EXIT_CODE_NO_ENVIRONMENT = 5; // Note this is used before definition in node_server.js +exports.EXIT_CODE_MONGODB_SSL_PEM_NOT_FOUND = 6; +exports.EXIT_CODE_NO_NODE_ENV = 7; +exports.EXIT_CODE_NO_ENVIRONMENT_VARS = 8; +exports.EXIT_CODE_NO_ACQUISITION_SERVER = 9; +exports.EXIT_CODE_NO_DEFAULT_IMAGES = 10; +exports.EXIT_CODE_CONFIG_FILE_ERROR = 11; // Note this is used before definition in node_server.js diff --git a/node_server/gulp.config.js b/node_server/gulp.config.js new file mode 100644 index 0000000..a38af9b --- /dev/null +++ b/node_server/gulp.config.js @@ -0,0 +1,194 @@ +module.exports = function() { + var config = { + // + // Test paths. Basically anything called *.spec.js in any of the paths + // we created (i.e. not node_modules) is a test. We also watch all + // JS files and re-run the tests if they change. + // + test: { + watchpaths: [ + '**/*.js', + '!node_modules/**' + ], + testpaths: [ + '**/*.spec.js', // Everything called *.spec.js + '!node_modules/**' // But not anything in node_modules + ] + }, + + // + // Generated API docs + // + api: { + src: './swagger_api/api_swagger_def.json', + dest: './docs/swagger_api/', + indexPath: 'overview.adoc', + options: { + dest: './docs/swagger_api/', + + pages: { + overview: './tools/docgen/templates/adoc-overview.handlebars', + paths: './tools/docgen/templates/adoc-paths.handlebars', + definitions: './tools/docgen/templates/adoc-definitions.handlebars', + responses: './tools/docgen/templates/adoc-response-definitions.handlebars', + }, + templates: { + parameters: './tools/docgen/templates/adoc-parameters.handlebars', + responses: './tools/docgen/templates/adoc-responses.handlebars', + schemaOrType: './tools/docgen/templates/adoc-schema-or-type.handlebars', + range: './tools/docgen/templates/adoc-range.handlebars', + propertiesRow: './tools/docgen/templates/adoc-properties-row.handlebars', + } + }, + watch: ['./tools/docgen/templates/*', './swagger_api/api_swagger_def.json'] + }, + + intApi: { + src: './integration_api/integration_swagger_def.json', + dest: './docs/integration_api/', + indexPath: 'overview.adoc', + options: { + dest: './docs/integration_api/', + + pages: { + overview: './tools/docgen/templates/adoc-overview.handlebars', + paths: './tools/docgen/templates/adoc-paths.handlebars', + definitions: './tools/docgen/templates/adoc-definitions.handlebars', + responses: './tools/docgen/templates/adoc-response-definitions.handlebars', + }, + templates: { + parameters: './tools/docgen/templates/adoc-parameters.handlebars', + responses: './tools/docgen/templates/adoc-responses.handlebars', + schemaOrType: './tools/docgen/templates/adoc-schema-or-type.handlebars', + range: './tools/docgen/templates/adoc-range.handlebars', + propertiesRow: './tools/docgen/templates/adoc-properties-row.handlebars', + } + }, + watch: ['./tools/docgen/templates/*', './integration_api/integration_swagger_def.json'] + }, + + // + // Docs from the wiki + // + wikidocs: { + dest: './docs/wiki/', + fileDest: './docs/wiki/files/', + fileDestRelative: './files', + schemaDest: './docs/generatedSchemas/', + indexPath: './docs/wiki/index.adoc', + sources: [ + // List of wiki slugs to download and add to the final document. + // They will be added to the page in the order they appear below. + // NOTE: the Web Dashboard API (swagger api) will always be last. + // Format of entries is: + // {slug: '', level: <1-based nesting depth in final file>}, + // + {slug: 'tricore_architecture/server_interface/introduction/', level: 0}, + + {slug: 'tricore_architecture/server_interface/', level: 1}, + + {slug: 'tricore_architecture/server_interface/registration_commands/', level: 2}, + {slug: 'tricore_architecture/server_interface/registration_commands/adddevice/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/deletedevice/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/getclientdetails/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/listdevices/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/register1/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/register2/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/register3/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/register4/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/register6/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/register8/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/resumedevice/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/setclientdetails/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/setdevicename/', level: 3}, + {slug: 'tricore_architecture/server_interface/registration_commands/suspenddevice/', level: 3}, + + {slug: 'tricore_architecture/server_interface/login_auth/', level: 2}, + {slug: 'tricore_architecture/server_interface/login_auth/accepteula/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/authorise2farequest/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/get2farequest/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/keepalive/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/login1/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/logout1/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/pinreset/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/rotatehmac/', level: 3}, + {slug: 'tricore_architecture/server_interface/login_auth/sessionauth/', level: 3}, + + {slug: 'tricore_architecture/server_interface/account_commands/', level: 2}, + {slug: 'tricore_architecture/server_interface/account_commands/addaddress/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/addcard/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/changepassword/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/changepin/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/deleteaccount/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/deleteaddress/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/gettransactiondetail/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/gettransactionhistory/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/listaccounts/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/listaddresses/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/setaccountaddress/', level: 3}, + {slug: 'tricore_architecture/server_interface/account_commands/setdefaultaccount/', level: 3}, + + {slug: 'tricore_architecture/server_interface/payment_commands/', level: 2}, + {slug: 'tricore_architecture/server_interface/payment_commands/cancelpaymentrequest/', level: 3}, + {slug: 'tricore_architecture/server_interface/payment_commands/confirmtransaction/', level: 3}, + {slug: 'tricore_architecture/server_interface/payment_commands/gettransactionupdate/', level: 3}, + {slug: 'tricore_architecture/server_interface/payment_commands/paycoderequest/', level: 3}, + {slug: 'tricore_architecture/server_interface/payment_commands/redeempaycode/', level: 3}, + {slug: 'tricore_architecture/server_interface/payment_commands/refundtransaction/', level: 3}, + + {slug: 'tricore_architecture/server_interface/invoice_commands/', level: 2}, + {slug: 'tricore_architecture/server_interface/invoice_commands/confirm_invoice/', level: 3}, + {slug: 'tricore_architecture/server_interface/invoice_commands/get_invoice/', level: 3}, + {slug: 'tricore_architecture/server_interface/invoice_commands/list_invoices/', level: 3}, + {slug: 'tricore_architecture/server_interface/invoice_commands/reject_invoice/', level: 3}, + + {slug: 'tricore_architecture/server_interface/image_commands/', level: 2}, + {slug: 'tricore_architecture/server_interface/image_commands/addimage/', level: 3}, + {slug: 'tricore_architecture/server_interface/image_commands/getimage/', level: 3}, + {slug: 'tricore_architecture/server_interface/image_commands/iconcache/', level: 3}, + {slug: 'tricore_architecture/server_interface/image_commands/imagecache/', level: 3}, + {slug: 'tricore_architecture/server_interface/image_commands/reportimage/', level: 3}, + + {slug: 'tricore_architecture/server_interface/merchant_commands/', level: 2}, + {slug: 'tricore_architecture/server_interface/merchant_commands/list_items/', level: 3}, + + {slug: 'tricore_architecture/server_interface/messaging_commands/', level: 2}, + {slug: 'tricore_architecture/server_interface/messaging_commands/deletemessage/', level: 3}, + {slug: 'tricore_architecture/server_interface/messaging_commands/getmessage/', level: 3}, + {slug: 'tricore_architecture/server_interface/messaging_commands/listmessages/', level: 3}, + {slug: 'tricore_architecture/server_interface/messaging_commands/markmessage/', level: 3}, + + {slug: 'tricore_architecture/logging/errorcodes/', level: 2}, + + {slug: 'webconsole/overview/', level: 1} + ], + watch: './docs/wiki/*' + }, + + // + // Configuration of the index generator + // + indexdocs: { + src: './docs/', + dest: './docs/', + indexPath: 'index.adoc', + fileDestRelative: './wiki/files', + options: { + dest: './docs/', + + pages: { + index: './tools/alldocs/templates/adoc-index.handlebars' + }, + templates: { + } + }, + watch: [ + './tools/alldocs/templates/*', + './tools/docgen/templates/*', + './swagger_api/api_swagger_def.json' + ] + } + }; + + return config; +}; diff --git a/node_server/gulpfile.js b/node_server/gulpfile.js new file mode 100644 index 0000000..8dc0bfb --- /dev/null +++ b/node_server/gulpfile.js @@ -0,0 +1,308 @@ +/* eslint-disable filenames/match-exported */ +/* eslint-disable no-console */ + +const config = require('./gulp.config')(); +const gulp = require('gulp'); +const mocha = require('gulp-spawn-mocha'); +const $ = require('gulp-load-plugins')({lazy: true}); +const exec = require('child_process').exec; +const args = require('yargs').argv; + +const DocGen = require('./tools/docgen/docgen.js'); +const WikiGen = require('./tools/wikidocs/wikidocs.js'); +const IndexGen = require('./tools/alldocs/alldocs.js'); +const SchemaGen = require('./tools/wikiToSchema/wikiToSchema.js'); + +/** + * List the available gulp tasks + */ +gulp.task('help', $.taskListing); +gulp.task('default', ['help']); + +/** + * Mocha test reporter can be passed on the command line + * + * Defaults to 'spec' as that is the most human-friendly, but e.g. + * can be changed to 'tap' as used for the arcanist integration + */ +let testReporter = 'spec'; +if (args.reporter) { + log('Custom unit test reporter: ' + $.util.colors.red(args.reporter)); + testReporter = args.reporter; +} + +/** + * Build the docs from the Swagger file + */ +gulp.task('swagger2asciidoc', (callback) => { + log('Generating the asciidoc from the swagger definition'); + + const options = config.api.options; + const promise = DocGen.Swagger2AsciiDoc(config.api.src, options); + promise.then(() => { + // All good + callback(); + return null; + }).catch((error) => { + // Failed somewhere + callback(error); + }); +}); + +gulp.task('swagger-asciidoc2html', ['swagger2asciidoc'], (callback) => { + log('Building the asciidoc into output formats'); + + const cmdLine = 'asciidoctor -d book ' + config.api.indexPath; + const options = { + cwd: config.api.dest + }; + exec(cmdLine, options, (err, stdout, stderr) => { + console.log(stdout); + console.log(stderr); + callback(err); + }); +}); + +/** + * Watch the docs source files, and regenerate the docs if it changes + */ +gulp.task('swagger-watcher', ['swagger-asciidoc2html'], () => { + gulp.watch([config.api.watch], ['swagger-asciidoc2html']); +}); + +/** + * Build the docs from the Swagger file + */ +gulp.task('integration2asciidoc', (callback) => { + log('Generating the asciidoc from the integration API swagger definition'); + + const options = config.intApi.options; + const promise = DocGen.Swagger2AsciiDoc(config.intApi.src, options); + promise.then(() => { + // All good + callback(); + return null; + }).catch((error) => { + // Failed somewhere + callback(error); + }); +}); + +gulp.task('integration-asciidoc2html', ['integration2asciidoc'], (callback) => { + log('Building the asciidoc into output formats'); + + const cmdLine = 'asciidoctor -a toc -a toclevels=3 -d book ' + config.intApi.indexPath; + const options = { + cwd: config.intApi.dest + }; + exec(cmdLine, options, (err, stdout, stderr) => { + console.log(stdout); + console.log(stderr); + callback(err); + }); +}); + +/** + * Watch the docs source files, and regenerate the docs if it changes + */ +gulp.task('integration-watcher', ['integration-asciidoc2html'], () => { + gulp.watch([config.intApi.watch], ['integration-asciidoc2html']); +}); + +/** + * Build the asciidoc files from the wiki pages + */ +gulp.task('wiki2asciidoc', (callback) => { + log('Generating the asciidoc from the wiki'); + + const options = config.wikidocs; + const promise = WikiGen.Wiki2AsciiDoc(options); + promise.then(() => { + // All good + callback(); + return null; + }).catch((error) => { + // Failed somewhere + callback(error); + }); +}); + +/** + * Build the html from the asciidoc files + */ +gulp.task('wiki-asciidoc2html', ['wiki2asciidoc'], (callback) => { + log('Building the wiki-based asciidoc into output formats'); + + let cmdLine = 'asciidoctor'; + cmdLine += ' --attribute imagesdir=' + config.wikidocs.fileDestRelative; + cmdLine += ' --attribute data-uri'; // Write images inline in the html + cmdLine += ' *.adoc'; + const options = { + cwd: config.wikidocs.dest + }; + exec(cmdLine, options, (err, stdout, stderr) => { + console.log(stdout); + console.log(stderr); + callback(err); + }); +}); + +/** + * Watch the asciidocs files, and regenerate the html if they change + */ +gulp.task('wiki-watcher', ['wiki-asciidoc2html'], () => { + gulp.watch([config.wikidocs.watch], ['wiki-asciidoc2html']); +}); + +/** + * Build the index file from the configuration + */ +gulp.task('index2asciidoc', ['swagger2asciidoc', 'wiki2asciidoc'], (callback) => { + log('Generating the asciidoc for the index'); + + const options = config; + const promise = IndexGen.GenerateIndex(options); + promise.then(() => { + // All good + callback(); + return null; + }).catch((error) => { + // Failed somewhere + callback(error); + }); +}); + +/** + * Build the html from the index asciidoc files + */ +gulp.task('index-asciidoc2html', ['index2asciidoc'], (callback) => { + log('Building the index asciidoc into output formats'); + + let cmdLine = 'asciidoctor'; + cmdLine += ' --attribute imagesdir=' + config.indexdocs.fileDestRelative; + cmdLine += ' --attribute data-uri'; // Write images inline in the html + cmdLine += ' -d book'; + cmdLine += ' ' + config.indexdocs.indexPath; + const options = { + cwd: config.indexdocs.dest + }; + exec(cmdLine, options, (err, stdout, stderr) => { + console.log(stdout); + console.log(stderr); + callback(err); + }); +}); + +/** + * Watch the asciidocs files, and regenerate the html if they change + */ +gulp.task('index-watcher', ['index-asciidoc2html'], () => { + gulp.watch([config.indexdocs.watch], ['index-asciidoc2Html']); +}); + +/** + * Task to build sample schemas from the wiki page + */ +gulp.task('wiki2schema', (callback) => { + log('Generating the schema samples from the wiki'); + + const options = config.wikidocs; + const promise = SchemaGen.Wiki2Schema(options); + promise.then(() => { + // All good + callback(); + return null; + }).catch((error) => { + // Failed somewhere + callback(error); + }); +}); + +/** + * Task to run moch tests on all files call *.spec.js + * This task allows errors to exit the program so that they can be picked up externally. + */ +gulp.task('test', () => { + console.log('Running tests...'); + gulp.src(config.test.testpaths) + .pipe(mocha({ + reporter: testReporter + })); +}); + +/** + * Task to run moch tests on all files call *.spec.js + * This differs from the above task in that it swallows all errors so that gulp.watch + * will not end prematurely when the tests fail. + */ +gulp.task('test-watched', () => { + console.log('Running tests...'); + gulp.src(config.test.testpaths) + .pipe($.plumber()) + .pipe(mocha({ + reporter: testReporter + })); +}); + +/** + * A watcher to automatically re-run unit tests when a watched file changes. + */ +gulp.task('test-watcher', ['test-watched'], () => { + gulp.watch(config.test.watchpaths, ['test-watched']); +}); + +/** + * Log a message or series of messages using chalk's blue color. + * Can pass in a string, object or array. + * + * @param {String|Object} msg - the message to log + */ +function log(msg) { + // eslint-disable-next-line lodash/prefer-lodash-typecheck + if (typeof (msg) === 'object') { + for (const item in msg) { + if (msg.hasOwnProperty(item)) { + $.util.log($.util.colors.blue(msg[item])); + } + } + } else { + $.util.log($.util.colors.blue(msg)); + } +} + +/** + * Bump the version + * --type=pre will bump the prerelease version *.*.*-x + * --type=patch or no flag will bump the patch version *.*.x + * --type=minor will bump the minor version *.x.* + * --type=major will bump the major version x.*.* + * --version=1.2.3 will bump to a specific version and ignore other flags + */ +gulp.task('bump', () => { + let msg = 'Bumping versions'; + const type = args.type; + const version = args.ver; + const options = {}; + if (version) { + options.version = version; + msg += ' to ' + version; + } else { + options.type = type; + msg += ' for a ' + type; + } + log(msg); + + gulp + .src('./package.json') + .pipe($.print()) + .pipe($.bump(options)) + .pipe(gulp.dest('./')); + + return gulp + .src('../package.json') + .pipe($.print()) + .pipe($.bump(options)) + .pipe(gulp.dest('../')); +}); + +module.exports = gulp; diff --git a/node_server/impl/confirm_transaction.js b/node_server/impl/confirm_transaction.js new file mode 100644 index 0000000..b4e56d1 --- /dev/null +++ b/node_server/impl/confirm_transaction.js @@ -0,0 +1,899 @@ +/** + * @fileOverview Implementation of confirming a transaction or an invoice. + */ +'use strict'; + +const Q = require('q'); +const _ = require('lodash'); +const debug = require('debug')('impl:confirm_transaction'); +const mongodb = require('mongodb'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const log = require(global.pathPrefix + 'log.js'); +const references = require(global.pathPrefix + '../utils/references.js'); +const acquirers = require(global.pathPrefix + '../utils/acquirers/acquirer.js'); + +/** + * List of errors that can be returned by this function + */ +const ERRORS = { + MERCHANT_NOT_FOUND: 'BRIDGE: Merchant Client details not found', + CLIENT_DETAILS_NOT_SET: 'BRIDGE: Client details not set', + MERCHANT_DETAILS_NOT_SET: 'BRIDGE: Merchant details not set', + CLIENT_KYC_INCOMPLETE: 'BRIDGE: Client KYC incomplete', + MERCHANT_KYC_INCOMPLETE: 'BRIDGE: Merchant KYC incomplete', + TRANSACTION_NOT_FOUND: 'BRIDGE: Transaction not found', + TRANSACTION_TOTAL_TOO_HIGH: 'BRIDGE: Amount + tip exceeds max value', + TRANSACTION_TOTAL_TOO_LOW: 'BRIDGE: Amount + tip is less than the min value', + FAILED_SET_CONFIRMED: 'BRIDGE: Failed to update transaction to CONFIRMED', + FAILED_SET_COMPLETE: 'BRIDGE: Failed to update transaction to COMPLETE', + FAILED_ADD_HISTORY: 'BRIDGE: Failed to add both transaction history entries', + FAILED_UPDATE_CUSTOMER_BALANCE: 'BRIDGE: Failed to update the customer balance', + FAILED_UPDATE_MERCHANT_BALANCE: 'BRIDGE: Failed to update the merchant balance', + MERCHANT_ACCOUNT_NOT_FOUND: 'BRIDGE: Merchant account is not found', + CUSTOMER_ACCOUNT_NOT_FOUND: 'BRIDGE: Customer account is not found', + MERCHANT_ACCOUNT_NOT_RECEIVING: 'BRIDGE: Merchant account is not a receiving account', + CUSTOMER_ACCOUNT_NOT_PAYMENTS: 'BRIDGE: Customer account is not a payment account' +}; + +/** + * Define the exports from this module + */ +module.exports = { + ERRORS: ERRORS, + confirmTransaction: confirmTransaction +}; + +/** + * Confirms a transaction, validating parameters, updating the database, and + * processing the payment through the merchant acquirer. + * + * @param {Object} client - The logged in client object from the database + * @param {Object} device - The device the client is using + * @param {Object} data - The data neccessary to process the transaction + * @param {String} data.TransactionID - the transaction ID + * @param {Number} [data.TipAmount] - any tip amount to add + * @param {String} data.ClientKey - the client key required to decrypt the payment details + * @param {Number} data.initialStatus - the status the transaction must initially have + * @param {String} data.ipAddress - ipAddress of the client + * @param {Number} [data.Latitude] - latitude of the request (only for invoices) + * @param {Number} [data.Longitude] - longitude of the request (only for invoices) + * @param {String} [data.AccountID] - account to pay with (only for invoices) + * + * @return {Promise} A promise for the result. Rejects with member of ERRORS on failure. + */ +function confirmTransaction(client, device, data) { + + // Get the transaction so we can validate it before we confirm it + let transP = getTransaction( + client.ClientID, + data.TransactionID, + data.initialStatus, + device.SessionToken, + device.DeviceToken + ); + + // + // Validate that both merchant and client have passed KYC checks + // + let merchantClientP = transP + .then((trans) => references.getClient(trans.MerchantClientID)) + .catch((err) => Q.reject(ERRORS.MERCHANT_NOT_FOUND)); + + let validateKycP = merchantClientP.then((merchant) => validateKYC(client, merchant)); + + let merchantAccountInfoP = transP.then( + (trans) => validateAccountInfo(trans.MerchantAccountID, trans.MerchantClientID) + ).then((info) => { + // Merchant account must be receiving ccount + if (info.account.ReceivingAccount !== 1) { + return Q.reject(ERRORS.MERCHANT_ACCOUNT_NOT_RECEIVING); + } else { + return info; + } + }).catch((err) => { + if (err.name === references.ERRORS.INVALID_ACCOUNT || + err.name === references.ERRORS.INVALID_ADDRESS + ) { + return Q.reject(ERRORS.MERCHANT_ACCOUNT_NOT_FOUND); + } else { + return Q.reject(err); + } + }); + + let customerAccountInfoP = transP.then( + (trans) => { + // For invoices, we are given the account ID directly + // For normal transactions it is in the transaction + const accountID = data.AccountID || trans.CustomerAccountID; + return validateAccountInfo(accountID, client.ClientID); + } + ).then((info) => { + // Cstuomer account must be payment account + if (info.account.PaymentsAccount !== 1) { + return Q.reject(ERRORS.CUSTOMER_ACCOUNT_NOT_PAYMENTS); + } else { + return info; + } + }).catch((err) => { + if (err.name === references.ERRORS.INVALID_ACCOUNT || + err.name === references.ERRORS.INVALID_ADDRESS + ) { + return Q.reject(ERRORS.CUSTOMER_ACCOUNT_NOT_FOUND); + } else { + return Q.reject(err); + } + }); + + // + // Validate the transaction values based on the accounts that are being paid + // from and to. + // + let grandTotalP = Q.all([transP, merchantAccountInfoP, customerAccountInfoP]).spread( + (trans, merchInfo, custInfo) => validateTransactionValue( + trans, + merchInfo.account, + custInfo.account, + data.TipAmount + )); + + // + // If everything passes validation then we can progress with the transaction. + // 1. Update the status of the transaction to Confirmed + // 2. Process the payment throught the acquirer + // 3. Update the status again to complete + // 4. Add the transactions history entries + // 5. Update the account balances + // 6. Check for errors + + // Step 1 - Update the transaction status to Confirmed + let confirmP = Q.all([ + transP, + merchantClientP, + validateKycP, + grandTotalP, + merchantAccountInfoP, + customerAccountInfoP + ]).spread((trans, merchantClient, validated, totalInfo, merchInfo, customerInfo) => { + log.system( + 'INFO', + 'Payment confirmed to ' + trans.MerchantClientID + '. Processing payment...', + 'confirm_transaction', + '', // No error to report + device.ClientID + ' (' + device.DeviceNumber + ')', + data.ipAddress + ); + + return setConfirmed(trans, totalInfo, data, device); + } + ); + + // Step 2 - Pay using the acquirer + let payP = Q.all([confirmP, merchantAccountInfoP, customerAccountInfoP]).spread( + (confirmedTrans, merchantInfo, customerInfo) => acquirers.payTransaction( + client, + device, + data, + confirmedTrans, + merchantInfo, + customerInfo + ) + ); + + // Step 3 - Update the transaction status to Complete + let completeP = Q.all([merchantAccountInfoP, payP]).spread( + (merchantInfo, response) => setComplete( + client.ClientID, + data.TransactionID, + merchantInfo.account, + response) + ); + + // Step 4 - Add the transaction history etnries + let historyP = completeP.then( + (updatedTransaction) => addTransactionHistories(updatedTransaction) + ); + + // Step 5 - Update the account balances + let balanceP = Q.all([merchantAccountInfoP, customerAccountInfoP, completeP, historyP]) + .spread((merchantInfo, customerInfo, updatedTransaction) => updateAccounts( + merchantInfo.account, + customerInfo.account, + updatedTransaction.TotalAmount + )); + + return Q.all([ + transP, + grandTotalP, + merchantAccountInfoP, + customerAccountInfoP, + confirmP, + payP, + completeP, + historyP, + balanceP + ]).then(() => { + return Q.resolve(); // All passed so just return a simple resolve + }).catch((err) => { + onPaymentError(data.TransactionID, data.initialStatus, err); + return Q.reject(err); + }); +} + +/** + * Gets the initial transaction so that we can confirm various details. + * + * @param {String} CustomerClientID - the ID of the customer + * @param {String} TransactionID - the ID of the transaction of interest + * @param {Number} initialStatus - the initial status the transaction must be in + * @param {String} SessionToken - the current client's session token + * @param {String} DeviceToken - the current client's device token + * + * @returns {Promise} Resolves to the transaction or rejects with ERRORS + */ +function getTransaction(CustomerClientID, TransactionID, initialStatus, SessionToken, DeviceToken) { + /** + * Check that the transaction id exists, is for this customer, and + * is in the required status (e.g. is not already progressing elsewhere) + */ + let query = { + _id: mongodb.ObjectID(TransactionID), + CustomerClientID: CustomerClientID, + TransactionStatus: initialStatus + }; + /** + * If this is a transaction (not an invoice) then we need to check the session + * and device tokens haven't changed since the paycode was requested. + */ + if (initialStatus === utils.TransactionStatus.CLAIMED) { + query.CustomerSessionToken = SessionToken; + query.CustomerDeviceToken = DeviceToken; + } + + const options = { + comment: 'confirmTransaction.getTransaction: find transaction' + }; + + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionTransaction, + query, + options, + false // Don't suppress errors + ).then(function(transaction) { + if (!transaction) { + return Q.reject(ERRORS.TRANSACTION_NOT_FOUND); + } else { + return transaction; + } + }); +} + +/** + * Validates that the customer and merchant KYC is fully up to date. We MUST + * NOT process transactions for clients where the KYC is incomplete. + * + * @param {Object} customer - the customer Client object + * @param {Object} merchant - the merchant Client object + * @returns {Promise} - resolves if ok, or rejects with an ERRORS value + */ +function validateKYC(customer, merchant) { + // Check customer details have been set + if (!utils.bitsAllSet(customer.ClientStatus, utils.ClientDetailsMask)) { + return Q.reject(ERRORS.CLIENT_DETAILS_NOT_SET); + } + // Check none of the "further details" flags are set + if (utils.bitsAnySet(customer.ClientStatus, utils.ClientKycIncompleteMask)) { + return Q.reject(ERRORS.CLIENT_KYC_INCOMPLETE); + } + + // Check merchant details have been set + if (!utils.bitsAllSet(merchant.ClientStatus, utils.ClientDetailsMask)) { + return Q.reject(ERRORS.MERCHANT_DETAILS_NOT_SET); + } + // Check none of the "further details" flags are set + if (utils.bitsAnySet(merchant.ClientStatus, utils.ClientKycIncompleteMask)) { + return Q.reject(ERRORS.MERCHANT_KYC_INCOMPLETE); + } + + return Q.resolve(); +} + +/** + * Checks that the total + tip does not exceed our max payment. + * + * @param {Object} transaction - the transaction object + * @param {Object} merchAcc - the merchant account that is to be paid into + * @param {Object} custAcc - the customer account that is to be paid from + * @param {Number} [TipAmount] - the optional tip amount + * + * @returns {Promise} - resolves to the grand total and tip, or rejects with an ERRORS + */ +function validateTransactionValue(transaction, merchAcc, custAcc, TipAmount) { + const tip = TipAmount || 0; // Undefined tips set to 0 + const newTotalAmount = transaction.RequestAmount + tip; + + /** + * Take the limits from the merchant account if it has them specified, + * or from the system limits if the individual account does not have specified limits + */ + const limits = _.defaultsDeep( + _.cloneDeep(merchAcc.Limits || {}), + { + debit: { + paymentMin: utils.paymentMin, + tipMin: utils.tipMin, + transactionMin: utils.transactionMin, + paymentMax: utils.paymentMax, + tipMax: utils.tipMax + }, + credit: { + paymentMin: utils.paymentMin, + tipMin: utils.tipMin, + transactionMin: utils.transactionMin, + paymentMax: utils.paymentMax, + tipMax: utils.tipMax + } + } + ); + + /** + * Select the appropriate limits according to the type of the customer account + * If the customer account doesn't have Details.AccountClass then default to "unknown" + * If the set of limits doesn't have an entry for the card type then default to credit limits + */ + const accountClass = _.get(custAcc, 'Details.AccountClass', utils.AccountClass.UNKNOWN); + const typeLimits = _.get(limits, accountClass, limits.credit); + + /** + * Now check the values against the limits we have identified. + */ + if (newTotalAmount > (typeLimits.paymentMax + typeLimits.tipMax)) { + return Q.reject(ERRORS.TRANSACTION_TOTAL_TOO_HIGH); + } + + if (newTotalAmount < typeLimits.transactionMin) { + return Q.reject(ERRORS.TRANSACTION_TOTAL_TOO_LOW); + } + + return Q.resolve({ + totalIncTip: newTotalAmount, + tip: tip + }); +} + +/** + * Validates that an account with the given ID exists. belongs to the given + * user, and has a valid address associated with it. + * + * @param {String} accountID - The ID of the account to look up + * @param {String} clientID - The ID of the client that should own the account + * @returns {Promise} - resolves to an object with account and address, else + * rejects with a references.ERRORS value + */ +function validateAccountInfo(accountID, clientID) { + let accountP = references.getAccount(accountID, clientID); + let addressP = accountP.then( + (account) => references.isValidAddressRef(clientID, account.BillingAddress) + ); + + return Q.all([accountP, addressP]).spread( + (account, address) => { + return { + account: account, + address: address + }; + } + ); +} + +/** + * Updates the status of the transaction to CONFIRMED, as well as updating the + * tip and total values + * + * @param {Object} transaction - the transaction object previously discovered + * @param {Object} totalInfo - information on the top and the total + * @param {Object} data - data object for optional Lat/Long + * @param {Object} device - the device the user is using + * @returns {Promise} - resolves to the updated transaction or rejects with ERROR + */ +function setConfirmed(transaction, totalInfo, data, device) { + /** + * We must re-check the parameters of the transaction when we go to update + * it in order to cover any race conditions where this may have been processed + * in parallel at the same time. + */ + let query = { + _id: mongodb.ObjectID(transaction._id), + CustomerClientID: transaction.CustomerClientID, + TransactionStatus: transaction.TransactionStatus + }; + /** + * If this is a transaction (not an invoice) then we need to check the session + * and device tokens haven't changed since the paycode was requested. + */ + if (data.initialStatus === utils.TransactionStatus.CLAIMED) { + query.CustomerSessionToken = device.SessionToken; + query.CustomerDeviceToken = device.DeviceToken; + } + + let update = { + $set: { + TransactionStatus: utils.TransactionStatus.CONFIRMED, + TotalAmount: totalInfo.totalIncTip, + TipAmount: totalInfo.tip, + StatusInfo: 'Processing transaction...', + + // Set the Customer device and session tokens in case this is an invoice + CustomerSessionToken: device.SessionToken, + CustomerDeviceToken: device.DeviceToken + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + // + // Add the optional location if set + // + if (data.Latitude && data.Longitude) { + update.$set.CustomerLocation = { + type: 'Point', + coordinates: [data.Longitude, data.Latitude] + }; + } + // + // Add the customer AccountID if set (for ConfirmInvoice) + // + if (data.AccountID) { + update.$set.CustomerAccountID = data.AccountID; + } + + const options = { + upsert: false, // Must be an update not a new entry + returnOriginal: false // We want the updated document + }; + + return mainDB.collectionTransaction.findOneAndUpdate(query, update, options) + .then((updateResult) => { + if (updateResult.lastErrorObject.updatedExisting === false) { + return Q.reject(ERRORS.FAILED_SET_CONFIRMED); + } else { + return updateResult.value; + } + }); +} + +/** + * Update the transaction to the COMPLETE status, adding the extra details from + * the acquirer response. + * + * @param {String} CustomerClientID - the customer ID + * @param {String} TransactionID - the transaction ID + * @param {Object} merchantAccount - the merchant account object + * @param {Object} response - the response from the acquirer + */ +function setComplete(CustomerClientID, TransactionID, merchantAccount, response) { + const query = { + _id: mongodb.ObjectID(TransactionID), + CustomerClientID: CustomerClientID, + TransactionStatus: utils.TransactionStatus.CONFIRMED // Can only update confirmed + }; + + // + // Default settings for the response parameters from the acquirer + // + const defaults = { + SaleReference: '', + SaleAuthCode: '', + RefundToken: '', + RiskScore: '', + GatewayResponse: '', + AVSResponse: '' + }; + + // + // Apply the defaults for any undefined properties + // + _.defaults(response, defaults); + + // + // Setup the update + // + const update = { + $set: { + TransactionStatus: utils.TransactionStatus.COMPLETE, + StatusInfo: 'Payment Complete', + SaleReference: response.SaleReference, + SaleAuthCode: response.SaleAuthCode, + RiskScore: response.RiskScore, + GatewayResponse: response.GatewayResponse, + AVSResponse: response.AVSResponse, + AcquirerName: merchantAccount.AcquirerName, + AcquirerMerchantID: merchantAccount.AcquirerMerchantID, + AcquirerCipher: merchantAccount.AcquirerCipher + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true, + SaleTime: true + } + }; + + const options = { + upsert: false, // Must be an update not a new entry + returnOriginal: false // We want the updated document + }; + + return mainDB.collectionTransaction.findOneAndUpdate(query, update, options) + .then((updateResult) => { + if (updateResult.lastErrorObject.updatedExisting === false) { + return Q.reject(ERRORS.FAILED_SET_COMPLETE); + } else { + return updateResult.value; + } + }); +} + +/** + * Creates the transaction history entries for the given transaction + * + * @param {Object} transaction - the transaction to add history entries for + * + * @return {Promise} - resolves or rejects with an ERRORS value + */ +function addTransactionHistories(transaction) { + /** + * Payment successful. Populate the customer history. + */ + const now = new Date(); + let newCustomerHist = mainDB.blankTransactionHistory(); + _.assign(newCustomerHist, { + TransactionID: transaction._id.toString(), + TransactionType: 0, // Outgoing + AccountID: transaction.CustomerAccountID, + ClientID: transaction.CustomerClientID, + OtherDisplayName: transaction.MerchantDisplayName, + OtherSubDisplayName: transaction.MerchantSubDisplayName, + OtherImage: transaction.MerchantImage, + MyLocation: transaction.CustomerLocation, + TotalAmount: transaction.TotalAmount, + SaleTime: transaction.SaleTime, + LastUpdate: now + }); + /** + * Add the MerchantInvoiceNumber if this is an invoice + */ + if (transaction.MerchantInvoiceNumber) { + newCustomerHist.MerchantInvoiceNumber = transaction.MerchantInvoiceNumber; + } + + let newMerchantHist = mainDB.blankTransactionHistory(); + _.assign(newMerchantHist, { + TransactionID: transaction._id.toString(), + TransactionType: 1, // Incoming + AccountID: transaction.MerchantAccountID, + ClientID: transaction.MerchantClientID, + OtherDisplayName: transaction.CustomerDisplayName, + OtherSubDisplayName: transaction.CustomerSubDisplayName, + OtherImage: transaction.CustomerImage, + MyLocation: transaction.MerchantLocation, + TotalAmount: transaction.TotalAmount, + SaleTime: transaction.SaleTime, + LastUpdate: now + }); + /** + * Add the MerchantInvoiceNumber if this is an invoice + */ + if (transaction.MerchantInvoiceNumber) { + newMerchantHist.MerchantInvoiceNumber = transaction.MerchantInvoiceNumber; + } + + // + // Insert both the items in a single call + // + var transactionHistItems = [newCustomerHist, newMerchantHist]; + return mainDB.collectionTransactionHistory.insertMany(transactionHistItems) + .then(function(result) { + if (result.insertedCount === 2) { + return Q.resolve(); + } else { + return Q.reject(ERRORS.CANT_INSERT_TRANSACTION_HISTORY); + } + }); +} + +/** + * Updates the balances for the customer and merchant + * + * @param {Object} merchantAccount - the merchant account + * @param {Object} customerAccount - the customer account + * @param {Object} totalAmount - the total amount of the transaction + * + * @returns {Promsie} - Resolves or rejects with an ERRORS value + */ +function updateAccounts(merchantAccount, customerAccount, totalAmount) { + const now = new Date(); + var updateCustomerAccountP = Q.resolve(); + if (customerAccount.BalanceAvailable !== 0) { + var updateCustomerQuery = { + _id: customerAccount._id + }; + var updateCustomerUpdate = { + $inc: { + TransactionTotal: totalAmount, + TotalWithdrawals: totalAmount, + Balance: (-1 * totalAmount), + LastVersion: 1 + }, + $set: { + LastUpdate: now + } + }; + var updateCustomerOptions = { + upsert: false + }; + + updateCustomerAccountP = Q.nfcall( + mainDB.updateObject, + mainDB.collectionAccount, + updateCustomerQuery, + updateCustomerUpdate, + updateCustomerOptions, + false // Don't suppress errors + ).then(function(result) { + if (result.result.n === 1) { + // A document was updated, so this is total success + return Q.resolve(); + } else { + // A document was not updated so this is a fail + return Q.reject(ERRORS.FAILED_UPDATE_CUSTOMER_BALANCE); + } + }); + } + + // + // Update the merchant balance + // + var updateMerchantAccountP = Q.resolve(); + if (merchantAccount.BalanceAvailable !== 0) { + var updateMerchantQuery = { + _id: merchantAccount._id + }; + var updateMerchantUpdate = { + $inc: { + TransactionTotal: totalAmount, + TotalDeposits: totalAmount, + Balance: totalAmount, + LastVersion: 1 + }, + $set: { + LastUpdate: now + } + }; + var updateMerchantOptions = { + upsert: false + }; + + updateMerchantAccountP = Q.nfcall( + mainDB.updateObject, + mainDB.collectionAccount, + updateMerchantQuery, + updateMerchantUpdate, + updateMerchantOptions, + false // Don't suppress errors + ).then(function(result) { + if (result.result.n === 1) { + // A document was updated, so this is total success + return Q.resolve(); + } else { + // A document was not updated so this is a fail + return Q.reject(ERRORS.FAILED_UPDATE_MERCHANT_BALANCE); + } + }); + } + + return Q.all([updateCustomerAccountP, updateMerchantAccountP]); +} + +/** + * Update the transaction status because the payment failed as appropriate + * + * @param {String} transactionId - The transaction that was trying to be paid + * @param {Number} initialStatus - The initial status so we know if invoice or transaction + * @param {Object} paymentError - Error info from the failed promise + */ +function onPaymentError(transactionId, initialStatus, paymentError) { + debug('onPaymentError: ', transactionId, paymentError, paymentError.stack); + + const isInvoice = (initialStatus === utils.TransactionStatus.PENDING_INVOICE); + /** + * Define error codes and response strings + */ + const newStatusInfo = isInvoice ? + errorToNewStatusInvoice(paymentError) : + errorToNewStatusTransaction(paymentError); + + /* + * If there's an update to be made to transaction then do it here. + * Note that we will return the original error even if the update fails. + */ + if (newStatusInfo.status !== null) { + var query = { + _id: mongodb.ObjectID(transactionId) + }; + + var update = { + $set: { + TransactionStatus: newStatusInfo.status, + StatusInfo: newStatusInfo.info + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + var options = { + upsert: false + }; + + mainDB.updateObject( + mainDB.collectionTransaction, + query, + update, + options, + false // Don't suppress errors + ); + } +} + +/** + * Gets the next status for a transaction based on the error. + * This function is for ConfirmTransaction and similar requests. + * + * @param {any} error - the error to convert + * @returns {Object} - object with new status info + */ +function errorToNewStatusTransaction(error) { + /* This simple function has a high complexity due to the large switch, so ignore */ + /* jshint -W074 */ + let newStatus = null; + let newStatusInfo = null; + + // + // Some errors have the error in `name`, while most just have it at the top level + // + const errorString = error.name || error; + newStatusInfo = errorString; + switch (errorString) { + // + // Errors due to customer and customer account + // + case ERRORS.CUSTOMER_ACCOUNT_NOT_FOUND: + case ERRORS.CUSTOMER_ACCOUNT_NOT_PAYMENTS: + case acquirers.ERRORS.INVALID_COMBINATION: + case acquirers.ERRORS.INVALID_CARD_DETAILS: + case acquirers.ERRORS.ACQUIRER_INVALID_PAYMENT_DETAILS: + case acquirers.ERRORS.CARD_EXPIRED: + newStatus = utils.TransactionStatus.NO_CUSTOMER; + break; + + // + // Errors due to merchant and merchant account + // + case ERRORS.MERCHANT_ACCOUNT_NOT_FOUND: + case ERRORS.MERCHANT_ACCOUNT_NOT_RECEIVING: + case acquirers.ERRORS.UNKNOWN_ACQUIRER: + case acquirers.ERRORS.INVALID_MERCHANT_NAME: + case acquirers.ERRORS.INVALID_MERCHANT_ACCOUNT_DETAILS: + case acquirers.ERRORS.ACQUIRER_MERCHANT_DISABLED: + case acquirers.ERRORS.ACQUIRER_UNAUTHORIZED: + newStatus = utils.TransactionStatus.NO_MERCHANT; + break; + + // + // Errors due to issues with processing the payment + // + case acquirers.ERRORS.ACQUIRER_BAD_REQUEST: + case acquirers.ERRORS.ACQUIRER_INTERNAL_SERVER_ERROR: + newStatus = utils.TransactionStatus.CANNOT_RECEIVE; + break; + + // + // Errors due to communication failures or other failuers that leaves unknown state + // + case acquirers.ERRORS.ACQUIRER_DOWN: + case acquirers.ERRORS.ACQUIRER_UNKNOWN_ERROR: + case acquirers.ERRORS.PAYMENT_FAILED_UNSPECIFIED: + newStatus = utils.TransactionStatus.ABORTED; + break; + } + + return { + status: newStatus, + info: newStatusInfo + }; +} + +/** + * Gets the next status for a invoices based on the error. + * This function is for ConfirmInvoice and similar requests. + * + * @param {any} error - the error to convert + * @returns {Object} - object with new status info + */ +function errorToNewStatusInvoice(error) { + /* This simple function has a high complexity due to the large switch, so ignore */ + /* jshint -W074 */ + let newStatus = null; + let newStatusInfo = null; + + // + // Some errors have the error in `name`, while most just have it at the top level + // + const errorString = error.name || error; + newStatusInfo = errorString; + switch (errorString) { + // + // Errors due to customer and customer account. + // Set these back to pending so the customer can try paying a different way + // + case ERRORS.CLIENT_DETAILS_NOT_SET: + case ERRORS.CLIENT_KYC_INCOMPLETE: + case ERRORS.CUSTOMER_ACCOUNT_NOT_FOUND: + case ERRORS.CUSTOMER_ACCOUNT_NOT_PAYMENTS: + case acquirers.ERRORS.INVALID_COMBINATION: + case acquirers.ERRORS.INVALID_CARD_DETAILS: + case acquirers.ERRORS.ACQUIRER_INVALID_PAYMENT_DETAILS: + case acquirers.ERRORS.CARD_EXPIRED: + newStatus = utils.TransactionStatus.PENDING_INVOICE; + break; + + // + // Errors due to merchant and merchant account + // Set these to queried so the merchant can fix them + // + case ERRORS.MERCHANT_DETAILS_NOT_SET: + case ERRORS.MERCHANT_KYC_INCOMPLETE: + case ERRORS.MERCHANT_ACCOUNT_NOT_FOUND: + case ERRORS.MERCHANT_ACCOUNT_NOT_RECEIVING: + case acquirers.ERRORS.UNKNOWN_ACQUIRER: + case acquirers.ERRORS.INVALID_MERCHANT_NAME: + case acquirers.ERRORS.INVALID_MERCHANT_ACCOUNT_DETAILS: + case acquirers.ERRORS.ACQUIRER_MERCHANT_DISABLED: + case acquirers.ERRORS.ACQUIRER_UNAUTHORIZED: + newStatus = utils.TransactionStatus.REJECTED_INVOICE; + break; + + // + // Errors due to issues with processing the payment + // Set back to pending so the customer can try again + // + case acquirers.ERRORS.ACQUIRER_BAD_REQUEST: + case acquirers.ERRORS.ACQUIRER_INTERNAL_SERVER_ERROR: + newStatus = utils.TransactionStatus.PENDING_INVOICE; + break; + + // + // Errors due to communication failures or other failuers that leaves unknown state + // Set these to Aborted as they may or may not have been completed + // + case acquirers.ERRORS.ACQUIRER_DOWN: + case acquirers.ERRORS.ACQUIRER_UNKNOWN_ERROR: + case acquirers.ERRORS.PAYMENT_FAILED_UNSPECIFIED: + newStatus = utils.TransactionStatus.ABORTED; + break; + } + + return { + status: newStatus, + info: newStatusInfo + }; +} diff --git a/node_server/impl/delete_account.js b/node_server/impl/delete_account.js new file mode 100644 index 0000000..e0a59f2 --- /dev/null +++ b/node_server/impl/delete_account.js @@ -0,0 +1,181 @@ +/** + * @fileOverview Handles deleting a client's account + */ + +/** + * Controller to manage the accounts functions + */ +'use strict'; + +const Q = require('q'); +const mongodb = require('mongodb'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const log = require(global.pathPrefix + 'log.js'); +const acquirerUtils = require(global.pathPrefix + '../utils/acquirers/acquirer.js'); + +/** + * Error values + */ +const ERRORS = { + RELATED_INVOICES: 'BRIDGE: RELATED INVOICES EXIST', + NOT_FOUND: 'BRIDGE: ACCOUNT NOT FOUND', + LOCKED: 'BRIDGE: ACCOUNT LOCKED', + FAILED_UPDATE: 'BRIDGE: FAILED UPDATE' +}; + +module.exports = { + ERRORS, + + deleteAccount +}; + +/** + * Deletes an account from the system by setting the "deleted" status. It also + * attempts to disable the token on the merchant aquirer system if appropriate. + * + * @param {string} clientID - ID of the client who's account is to be deleted. + * @param {string} accountID - Express response object. + * + * @returns {Promise} - Promise for success, or reject with ERRORS value. + */ +function deleteAccount(clientID, accountID) { + // + // Check that this account doesn't have any pending invoices that are + // expecting payment into this account. + // + const invoiceQuery = { + MerchantClientID: clientID, + MerchantAccountID: accountID, + TransactionStatus: { + $in: [ + utils.TransactionStatus.PENDING_INVOICE, + utils.TransactionStatus.REJECTED_INVOICE + ] + } + }; + const invoiceOptions = { + comment: 'WebConsole: find related invoices for deleteAccount' + }; + const findInvoiceP = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionTransaction, + invoiceQuery, + invoiceOptions, + false + ).then((item) => { + if (item) { + return Q.reject({name: ERRORS.RELATED_INVOICES}); + } else { + return Q.resolve(item); + } + }); + + // + // Find the account + // + const findQuery = { + _id: mongodb.ObjectID(accountID), // The account id + ClientID: clientID, // Must belong to *me* + AccountStatus: { + // Must not be "deleted". "Locked" is checked later + $bitsAllClear: utils.AccountDeleted + } + }; + const options = { + comment: 'WebConsole: find for deleteAccount' + }; + + const findP = findInvoiceP.then(() => { + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionAccount, + findQuery, + options, + false + ).then((item) => { + if (!item) { + return Q.reject({name: ERRORS.NOT_FOUND}); + } else if (utils.bitsAllSet(item.AccountStatus, utils.AccountLocked)) { + return Q.reject({name: ERRORS.LOCKED}); + } else { + return Q.resolve(item); + } + }); + }); + + // + // Try and disable the token + // + const disableP = findP.then((item) => { + return acquirerUtils.invalidateMerchantAccount( + item.AcquirerName, + item.Token, + item.AcquirerMerchantID, + item.AcquirerCipher, + item._id.toString() + ).then((response) => { + // + // If we have a response value, it's because it failed to delete + // the token. This can happen if e.g. the card has already expired + // + if (response) { + // + // Because this can fail for "ok" reasons (e.g. expired credit + // card), we still resolve it. But also log a reason. + log.system( + 'ERROR', + 'Cannot lock the token with the acquiring bank (Credorax).', + 'deleteAccount', + '245', + item.ClientID, + ''); + } + + return Q.resolve(); + }); + }); + + // + // Finally, 'delete' the account by setting the 'deleted' flag + // + const deleteP = disableP.then(() => { + const updates = { + $bit: { + AccountStatus: {or: utils.AccountDeleted} + }, + $set: { + LastUpdate: new Date() + }, + $inc: { + LastVersion: 1 + } + }; + + const updateOptions = { + upsert: false, + multi: false + }; + + return Q.nfcall( + mainDB.updateObject, + mainDB.collectionAccount, + findQuery, + updates, + updateOptions, + false + ).then((results) => { + if (results.result.n === 0) { + return Q.reject({name: ERRORS.FAILED_UPDATE}); + } else { + return Q.resolve(); + } + }); + }); + + // + // Run all the promises and return the result + // + return Q.all([findInvoiceP, findP, disableP, deleteP]); +} diff --git a/node_server/impl/get_transaction_update.js b/node_server/impl/get_transaction_update.js new file mode 100644 index 0000000..4bfb40d --- /dev/null +++ b/node_server/impl/get_transaction_update.js @@ -0,0 +1,269 @@ +/** + * @fileOverview Node.js GetTransactionUpdate Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Allows a user to check a transaction to see what's happening. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/gettransactionupdate/} + */ +'use strict'; + +module.exports = { + getTransactionUpdate: getTransactionUpdate +}; + +/** + * Includes + */ +const _ = require('lodash'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var log = require(global.pathPrefix + 'log.js'); +var auth = require(global.pathPrefix + 'auth.js'); +var mongodb = require('mongodb'); + +/** + * Gets an update on the status of the specified transaction, for either the + * customer or the merchant depending on who the caller is. + * + * @param {Object} receivedObject - the data in the request (see wiki) + * @param {Function} cb - the callback function in normal express (err, result) style + */ +function getTransactionUpdate(receivedObject, cb) { + + /** + * Either the payer or payee can cancel the transaction. + */ + //jshint -W074 + mainDB.findOneObject(mainDB.collectionTransaction, { + _id: mongodb.ObjectID(receivedObject.TransactionID) + }, undefined, false, function(err, existingTransaction) { + if (err) { + cb({ + code: '171', + info: 'Database offline.' + }); + return; + } + + /** + * Check to see if the transaction exists. + */ + if (!existingTransaction) { + cb({ + code: '172', + info: 'Cannot find transaction.' + }); + return; + } + + /** + * Find out if it's the merchant or customer calling. If not, report an error. + * If this is true it indicated a session timeout. + */ + if (!(((receivedObject.DeviceToken === existingTransaction.CustomerDeviceToken) && + (receivedObject.SessionToken === existingTransaction.CustomerSessionToken)) || + ((receivedObject.DeviceToken === existingTransaction.MerchantDeviceToken) && + (receivedObject.SessionToken === existingTransaction.MerchantSessionToken)))) { + cb({ + code: '173', + info: 'Session timed out.' + }); + return; + } + + /** + * Respond depending on transaction status. + */ + switch (existingTransaction.TransactionStatus) { + case 0: + /** + * Check for an expired paycode. This prevents the app sitting indefinitely on the PayCode screen. + */ + var newLastUpdate = new Date(); + if (newLastUpdate > existingTransaction.PayCodeExpiry) { + var newTransactionStatus = 17; + var newStatusInfo = 'PayCode expired before use.'; + var newLastVersion = existingTransaction.LastVersion + 1; + mainDB.updateObject(mainDB.collectionTransaction, { + _id: mongodb.ObjectID(receivedObject.TransactionID) + }, { + $set: { + TransactionStatus: newTransactionStatus, + StatusInfo: newStatusInfo, + LastUpdate: newLastUpdate, + LastVersion: newLastVersion + } + }, { + upsert: false + }, false, function(err) { + if (err) { + cb({ + code: '319', + info: 'Database offline.' + }); + return; + } + /** + * PayCode should have been automatically deleted by Mongo so simply return. + */ + cb({ + code: '320', + info: 'Paycode expired.' + }); + }); + return; + } + + /** + * Paycode is still valid. + */ + cb( + null, { + code: '10019', + info: existingTransaction.StatusInfo + }); + break; + case 1: + /** + * Code claimed. Respond differently to customer/merchant. + * The first response is the Merchant response. + */ + if (!((receivedObject.DeviceToken === existingTransaction.CustomerDeviceToken) && + (receivedObject.SessionToken === existingTransaction.CustomerSessionToken))) { + cb( + null, { + code: '10021', + info: existingTransaction.StatusInfo + }); + return; + } + + /** + * Customer response. + */ + var newRequestTip; + if (existingTransaction.TipAmount === null) { + newRequestTip = 0; + } else { + newRequestTip = 1; + } + + /** + * Process Invoice if present. + */ + var newMerchantInvoice = null; + if (existingTransaction.MerchantInvoice) { + newMerchantInvoice = []; + var newInvoiceItem; + var itemCount = Object.keys(existingTransaction.MerchantInvoice).length; + /** + * Iterate the array to add refunded placeholder. + */ + for (var counter = 0; counter !== itemCount; counter++) { + // + // Pick some parameters from the transaction to return + // + const parameters = [ + 'Item_Description', 'Item_VATRate', 'Item_Quantity', + 'Line_VATAmount', 'Line_TotalAmount' + ]; + newInvoiceItem = _.pick(existingTransaction.MerchantInvoice[counter], parameters); + newMerchantInvoice.push(newInvoiceItem); + } + } + + /** + * Respond to request. + */ + cb( + null, { + code: '10021', + info: existingTransaction.StatusInfo, + MerchantDisplayName: existingTransaction.MerchantDisplayName, + MerchantSubDisplayName: existingTransaction.MerchantSubDisplayName, + MerchantVATNo: existingTransaction.MerchantVATNo, + MerchantImage: existingTransaction.MerchantImage, + RequestAmount: existingTransaction.RequestAmount, + RequestTip: newRequestTip, + MerchantInvoice: newMerchantInvoice, + MerchantComment: existingTransaction.MerchantComment + }); + break; + case 2: + /** + * Payment underway. + */ + cb( + null, { + code: '10029', + info: existingTransaction.StatusInfo + }); + break; + case 3: + /** + * Transaction complete. Customer response (first) and if not it must be the merchant. + */ + if ((receivedObject.DeviceToken === existingTransaction.CustomerDeviceToken) && + (receivedObject.SessionToken === existingTransaction.CustomerSessionToken)) { + cb( + null, { + code: '10024', + info: existingTransaction.StatusInfo, + MerchantDisplayName: existingTransaction.MerchantDisplayName, + MerchantSubDisplayName: existingTransaction.MerchantSubDisplayName, + MerchantImage: existingTransaction.MerchantImage, + TotalAmount: existingTransaction.TotalAmount + }); + } else { + cb( + null, { + code: '10024', + info: existingTransaction.StatusInfo, + CustomerDisplayName: existingTransaction.CustomerDisplayName, + CustomerSubDisplayName: existingTransaction.CustomerSubDisplayName, + CustomerImage: existingTransaction.CustomerImage, + TotalAmount: existingTransaction.TotalAmount + }); + } + break; + case 10: // Cancelled before use. + case 11: // Cancelled after Paycode redemption. + case 12: // Declined by payment processing system. Contact your bank. + case 13: // Customer account deleted. + case 14: // Merchant account deleted. + case 15: // Merchant account is not a receiving account. + case 16: // Transaction aborted. + case 17: // Paycode expired. + /** + * Returned due to the various codes shown above. + */ + cb( + null, { + code: '10022', + info: existingTransaction.StatusInfo + }); + break; + case 4: + /** + * Transaction has been refunded. + */ + cb( + null, { + code: '10037', + info: existingTransaction.StatusInfo + }); + break; + default: + /** + * Unrecognised TransactionStatus. + */ + cb({ + code: '234', + info: 'Invalid TransactionStatus.' + }); + break; + } + }); + //jshint +W074 +} diff --git a/node_server/impl/redeem_paycode.js b/node_server/impl/redeem_paycode.js new file mode 100644 index 0000000..2e65010 --- /dev/null +++ b/node_server/impl/redeem_paycode.js @@ -0,0 +1,344 @@ +/* eslint-disable no-throw-literal*/ +/** + * @fileOverview Node.js Redeem PayCode Handler for Bridge Pay + * @preserve Copyright 2016 Comcarde Ltd. + * + * Allows a merchant to add their details to a transaction. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/redeempaycode/} + */ + +/** + * Includes11 + */ +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const mainDBP = require(global.pathPrefix + 'mainDB-promises.js'); +const valid = require(global.pathPrefix + 'valid.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const config = require(global.configFile); +const mongodb = require('mongodb'); + +module.exports = { + redeemPaycodeP +}; + +/** + * Implements the redeem paycode functionality in a centralised function to + * allow it to be used from different APIs. + * Note that this does require receivedObject to include the DeviceToken and + * SessionToken fields for adding to the transaction. As these are not part of + * e.g. the Integrations aAPI, those callers should use something suitable. + * + * @param {Object} existingClient - The client object for the merchant + * @param {Object} receivedObject - The data in the request + */ +// eslint-disable-next-line consistent-return +async function redeemPaycodeP(existingClient, receivedObject) { // eslint-disable-line complexity + try { + /** + * Validate the input on a functional basis (i.e. to the values total etc.?) + */ + const validationResponse = valid.validateRedeemPayCode(receivedObject); + if (validationResponse) { + throw { + code: validationResponse.code.toString(), + info: validationResponse.message + }; + } + + /** + * Check that display names are valid. Otherwise, do not allow the Client to continue. + */ + if (utils.MinDisplayNameLength > existingClient.DisplayName.length) { + throw { + code: '474', + info: 'DisplayName is invalid. Please fill out customer details.' + }; + } + if ((utils.MinDisplayNameLength > existingClient.Merchant[0].CompanyAlias.length) && + (existingClient.Merchant[0].MerchantStatus === utils.MerchantStatusActive)) { + throw { + code: '475', + info: 'CompanyAlias is invalid. Please fill out Merchant details.' + }; + } + + /** + * Prevent non-merchant accounts from requesting a tip. + */ + if ((existingClient.Merchant[0].MerchantStatus !== utils.MerchantStatusActive) && + (receivedObject.RequestTip === 1)) { + throw { + code: '476', + info: 'Only Merchants can request a tip.' + }; + } + + /** + * Find the PayCode. + */ + const existingPaycode = await mainDBP.findOneObjectPWithCode( + mainDB.collectionPayCode, + {PayCode: receivedObject.PayCode}, + undefined, + false, + '175'); + + /** + * Check that the paycode exists. + */ + if (!existingPaycode) { + throw { + code: '176', + info: 'Invalid PayCode.' + }; + } + + /** + * Delete the paycode from the database. + */ + await mainDBP.removeObjectPWithCode( + mainDB.collectionPayCode, + {PayCode: receivedObject.PayCode}, + undefined, + false, + '177'); + + /** + * Paycode redeemed. Find the transaction. + */ + const existingTransaction = await mainDBP.findOneObjectPWithCode( + mainDB.collectionTransaction, + {_id: mongodb.ObjectID(existingPaycode.TransactionID)}, + undefined, + false, + '178'); + + /** + * Check there is a transaction and that the status is 0. + */ + if ((!existingTransaction) || (existingTransaction.TransactionStatus !== 0)) { + throw { + code: '179', + info: 'Invalid TransactionID.' + }; + } + + /** + * Get the account information and fill in transaction. + */ + const newLastUpdate = new Date(); + const newLastVersion = existingTransaction.LastVersion + 1; + const existingMerchantAccount = await mainDBP.findOneObjectPWithCode( + mainDB.collectionAccount, + {_id: mongodb.ObjectID(receivedObject.AccountID)}, + undefined, + false, + '229'); + + /** + * Ensure that we got an account back. + */ + if (!existingMerchantAccount) { + throw { + code: '276', + info: 'Invalid merchant AccountID.' + }; + } + + /** + * Check that the acount has a valid billing address. The account cannot be used unless it is. + */ + if (existingMerchantAccount.BillingAddress === '') { + throw { + code: '491', + info: 'No valid billing address.' + }; + } + + /** + * Check that this is not a deleted account. + * This is a valid bitwise compare. Legacy code. + */ + let newStatusInfo = ''; + let newTransactionStatus = 0; + if (utils.bitsAllSet(existingMerchantAccount.AccountStatus, utils.AccountDeleted)) { + newStatusInfo = 'Merchant account deleted.'; + newTransactionStatus = 14; + await mainDBP.updateObjectPWithCode(mainDB.collectionTransaction, + {_id: mongodb.ObjectID(existingPaycode.TransactionID)}, { + $set: { + TransactionStatus: newTransactionStatus, + StatusInfo: newStatusInfo, + LastUpdate: newLastUpdate, + LastVersion: newLastVersion + } + }, + {upsert: false}, + false, + '279'); + + throw { + code: '275', + info: 'Deleted merchant AccountID.' + }; + } + + /** + * Check that the account can receive payments. + */ + if (existingMerchantAccount.ReceivingAccount !== 1) { + newStatusInfo = 'Merchant account is not a receiving account.'; + newTransactionStatus = 15; + await mainDBP.updateObjectPWithCode(mainDB.collectionTransaction, + {_id: mongodb.ObjectID(existingPaycode.TransactionID)}, { + $set: { + TransactionStatus: newTransactionStatus, + StatusInfo: newStatusInfo, + LastUpdate: newLastUpdate, + LastVersion: newLastVersion + } + }, + {upsert: false}, + false, + '296'); + throw { + code: '297', + info: 'Account cannot receive payment.' + }; + } + + /** + * Fill in account details. + */ + let newMerchantDisplayName = ''; + let newMerchantSubDisplayName = ''; + let newMerchantImage = ''; + let newMerchantVATNo = null; + switch (existingMerchantAccount.UserImage) { + case 'Selfie': + newMerchantDisplayName = existingClient.DisplayName; + newMerchantImage = existingClient.Selfie; + break; + case 'defaultSelfie': + newMerchantDisplayName = existingClient.DisplayName; + newMerchantImage = config.defaultSelfie; + break; + case 'CompanyLogo0': + newMerchantDisplayName = existingClient.Merchant[0].CompanyAlias; + newMerchantSubDisplayName = existingClient.Merchant[0].CompanySubName; + newMerchantImage = existingClient.Merchant[0].CompanyLogo; + if (existingClient.Merchant[0].VATNo) { + newMerchantVATNo = existingClient.Merchant[0].VATNo; + } + break; + case 'defaultCompanyLogo0': + newMerchantDisplayName = existingClient.Merchant[0].CompanyAlias; + newMerchantSubDisplayName = existingClient.Merchant[0].CompanySubName; + newMerchantImage = config.defaultCompanyLogo0; + if (existingClient.Merchant[0].VATNo) { + newMerchantVATNo = existingClient.Merchant[0].VATNo; + } + break; + default: + /** + * Error condition. + */ + throw { + code: '231', + info: 'Invalid image details.' + }; + } + + /** + * Add the MerchantComment if present. + */ + let newMerchantComment = ''; + if (receivedObject.MerchantComment) { + newMerchantComment = receivedObject.MerchantComment; + } + + /** + * Deal with the tip. + */ + let newTipAmount = null; + if (receivedObject.RequestTip) { + if (receivedObject.RequestTip === 1) { + newTipAmount = 0; + } + } + + /** + * Add the merchant invoice if it is available. + */ + let newMerchantInvoice = null; + if (receivedObject.MerchantInvoice) { + /** + * Iterate all line items to add the refund line. + */ + newMerchantInvoice = receivedObject.MerchantInvoice; + const itemCount = Object.keys(receivedObject.MerchantInvoice).length; + + /** + * Iterate the array to add refunded placeholder. + * Legacy error on code depth removed and camelCase removed here. + */ + for (let counter = 0; counter !== itemCount; counter++) { + newMerchantInvoice[counter].Items_Refunded = null; + } + } + + /** + * Add merchant location if available. + */ + let newMerchantLocation = null; + if ((receivedObject.Longitude !== null) && (receivedObject.Latitude !== null)) { + newMerchantLocation = { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }; + } + + /** + * Push the changes. + */ + await mainDBP.updateObjectPWithCode(mainDB.collectionTransaction, + {_id: mongodb.ObjectID(existingPaycode.TransactionID)}, { + $set: { + PayCodeID: 'Redeemed.', + MerchantDeviceToken: receivedObject.DeviceToken, + MerchantSessionToken: receivedObject.SessionToken, + MerchantAccountID: receivedObject.AccountID, + MerchantClientID: existingClient.ClientID, + MerchantDisplayName: newMerchantDisplayName, + MerchantSubDisplayName: newMerchantSubDisplayName, + MerchantVATNo: newMerchantVATNo, + MerchantImage: newMerchantImage, + MerchantInvoice: newMerchantInvoice, + MerchantComment: newMerchantComment, + TransactionStatus: 1, + RequestAmount: receivedObject.RequestAmount, + TipAmount: newTipAmount, + StatusInfo: 'Paycode redeemed. Waiting for customer...', + MerchantLocation: newMerchantLocation, + LastUpdate: newLastUpdate, + LastVersion: newLastVersion + } + }, + {upsert: false}, + false, + '175'); + + /** + * Success! + */ + return { + code: '10020', + info: 'PayCode redeemed.', + TransactionID: existingPaycode.TransactionID + }; + } catch (error) { + if (error) { + throw error; + } + } +} diff --git a/node_server/impl/specs/redeem_paycode.spec.js b/node_server/impl/specs/redeem_paycode.spec.js new file mode 100644 index 0000000..0820f49 --- /dev/null +++ b/node_server/impl/specs/redeem_paycode.spec.js @@ -0,0 +1,1642 @@ +/** + * Unit testing file for RedeemPaycode command + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 5] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); +const mongodb = require('mongodb'); + +const sandbox = sinon.sandbox.create(); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const redeemPaycodeClass = rewire('../redeem_paycode.js'); +const validStub = redeemPaycodeClass.__get__('valid'); +const mainDBPStub = redeemPaycodeClass.__get__('mainDBP'); + +const expect = chai.expect; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +/** + * Define a sample Client and Device object to return + */ +const DEVICE_TOKEN = 'abc123'; +const SESSION_TOKEN = 'def456'; +const ACCOUNTID = '58e3a700f50f21000166b890'; +const PAYCODE = 'AAAAA'; +const INVALID_PAYCODE = 'Z'; +const PAYCODEOBJECT = { + PayCode: 'AAAAA', + TransactionID: '5a0ef9e35a04b54fb0dd352f' +}; +const TRANSACTIONOBJECT = { + TransactionStatus: 0, + LastVersion: 1 +}; +const INVALID_TRANSACTIONOBJECT = { + TransactionStatus: 1, + LastVersion: 1 +}; +const MERCHANTOBJECT = { + BillingAddress: '5a0ef76a5a04b54fb0dd34c6', + AccountStatus: 0x00, + ReceivingAccount: 1, + UserImage: 'Selfie' +}; +const COMPANYLOGO_MERCHANTOBJECT = { + BillingAddress: '5a0ef76a5a04b54fb0dd34c6', + AccountStatus: 0x00, + ReceivingAccount: 1, + UserImage: 'CompanyLogo0' +}; +const NOBILLINGADDRESS_MERCHANTOBJECT = { + BillingAddress: '', + AccountStatus: 0x00, + ReceivingAccount: 1, + UserImage: 'Selfie' +}; +const DELETED_MERCHANTOBJECT = { + BillingAddress: '5a0ef76a5a04b54fb0dd34c6', + AccountStatus: 0x02, + ReceivingAccount: 1, + UserImage: 'Selfie' +}; +const NOTRECEIVEINGACCOUNT_MERCHANTOBJECT = { + BillingAddress: '5a0ef76a5a04b54fb0dd34c6', + AccountStatus: 0x00, + ReceivingAccount: 0, + UserImage: 'Selfie' +}; +const INVALIDUSERIMAGE_MERCHANTOBJECT = { + BillingAddress: '5a0ef76a5a04b54fb0dd34c6', + AccountStatus: 0x00, + ReceivingAccount: 1, + UserImage: 'defaultelfie' +}; + +const MERCHANTCOMMENT = 'You were served today by Stuey.'; +const REQUESTAMOUNT = 399; +const REQUESTTIP = 1; +const LATITUDE = 0.0; +const LONGITUDE = 0.0; + +const VALID_CLIENT = { + DisplayName: 'Richard Vanneck', + Selfie: 'Selfie', + Merchant: [ + { + MerchantStatus: 1, + CompanyAlias: 'Vanneck\'s Vegan Van', + CompanySubName: '', + CompanyLogo: 'asaf451dff23dff234', + VATNo: 'GB0000000000' + } + ] +}; +const SHORT_DISPLAYNAME_CLIENT = { + DisplayName: 'R', + Selfie: 'Selfie', + Merchant: [ + { + MerchantStatus: 1, + CompanyAlias: 'Vanneck\'s Vegan Van' + } + ] +}; +const SHORT_COMPANYALIAS_CLIENT = { + DisplayName: 'Richard Vanneck', + Selfie: 'Selfie', + Merchant: [ + { + MerchantStatus: 1, + CompanyAlias: 'V' + } + ] +}; +const NOTMERCHANT_CLIENT = { + DisplayName: 'Richard Vanneck', + Selfie: 'Selfie', + Merchant: [ + { + MerchantStatus: 0, + CompanyAlias: '' + } + ] +}; + +describe('redeem_paycode', () => { + let callP; + let receivedObject; + let pError; + let now; + + beforeEach(() => { + now = new Date(); + sandbox.useFakeTimers(now.getTime()); + sandbox.stub(validStub, 'validateRedeemPayCode').returns(); + sandbox.stub(mainDBPStub, 'removeObjectPWithCode').resolves(); + sandbox.stub(mainDBPStub, 'findOneObjectPWithCode') + .onFirstCall().resolves(PAYCODEOBJECT) + .onSecondCall().resolves(TRANSACTIONOBJECT) + .onThirdCall().resolves(MERCHANTOBJECT); + sandbox.stub(mainDBPStub, 'updateObjectPWithCode').resolves(); + + receivedObject = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + AccountID: ACCOUNTID, + PayCode: PAYCODE, + MerchantComment: MERCHANTCOMMENT, + RequestAmount: REQUESTAMOUNT, + RequestTip: REQUESTTIP, + Latitude: LATITUDE, + Longitude: LONGITUDE + }; + }); + + /** + * After each tests, reset the stubs. + */ + afterEach(() => { + sandbox.restore(); + callP = null; + receivedObject = null; + pError = null; + }); + + describe('with valid parameters', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + callP = await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds all objects', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('updates transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + PayCodeID: 'Redeemed.', + MerchantDeviceToken: receivedObject.DeviceToken, + MerchantSessionToken: receivedObject.SessionToken, + MerchantAccountID: receivedObject.AccountID, + MerchantClientID: VALID_CLIENT.ClientID, + MerchantDisplayName: VALID_CLIENT.DisplayName, + MerchantSubDisplayName: '', + MerchantVATNo: null, + MerchantImage: VALID_CLIENT.Selfie, + MerchantInvoice: null, + MerchantComment: receivedObject.MerchantComment, + TransactionStatus: 1, + RequestAmount: receivedObject.RequestAmount, + TipAmount: 0, + StatusInfo: 'Paycode redeemed. Waiting for customer...', + MerchantLocation: { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }, + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }) + ); + }); + it('responds', () => { + return expect(callP).to.deep.equal({ + code: '10020', + info: 'PayCode redeemed.', + TransactionID: '5a0ef9e35a04b54fb0dd352f' + }); + }); + }); + describe('with valid parameters with merchant invoice', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + receivedObject = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + AccountID: ACCOUNTID, + PayCode: PAYCODE, + MerchantInvoice: [ + { + Item_ID: '764aa907908f72332093c651', + Item_Code: '98768926735178', + Item_Description: '10cm Brush', + Item_VATCode: 'T1', + Item_VATRate: 10000, + Item_NetAmount: 25000, + Item_GrossAmount: 25000, + Item_Quantity: 32000, + Line_VATAmount: 25000, + Line_TotalAmount: 25000 + } + ], + MerchantComment: MERCHANTCOMMENT, + RequestAmount: REQUESTAMOUNT, + RequestTip: REQUESTTIP, + Latitude: LATITUDE, + Longitude: LONGITUDE + }; + + callP = await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds all objects', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('updates transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + PayCodeID: 'Redeemed.', + MerchantDeviceToken: receivedObject.DeviceToken, + MerchantSessionToken: receivedObject.SessionToken, + MerchantAccountID: receivedObject.AccountID, + MerchantClientID: VALID_CLIENT.ClientID, + MerchantDisplayName: VALID_CLIENT.DisplayName, + MerchantSubDisplayName: '', + MerchantVATNo: null, + MerchantImage: VALID_CLIENT.Selfie, + MerchantInvoice: [ + { + Item_ID: '764aa907908f72332093c651', + Item_Code: '98768926735178', + Item_Description: '10cm Brush', + Item_VATCode: 'T1', + Item_VATRate: 10000, + Item_NetAmount: 25000, + Item_GrossAmount: 25000, + Item_Quantity: 32000, + Line_VATAmount: 25000, + Line_TotalAmount: 25000, + Items_Refunded: null + } + ], + MerchantComment: receivedObject.MerchantComment, + TransactionStatus: 1, + RequestAmount: receivedObject.RequestAmount, + TipAmount: 0, + StatusInfo: 'Paycode redeemed. Waiting for customer...', + MerchantLocation: { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }, + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }) + ); + }); + it('responds', () => { + return expect(callP).to.deep.equal({ + code: '10020', + info: 'PayCode redeemed.', + TransactionID: '5a0ef9e35a04b54fb0dd352f' + }); + }); + }); + describe('with valid parameters with company logo', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(COMPANYLOGO_MERCHANTOBJECT); + + callP = await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds all objects', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('updates transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + PayCodeID: 'Redeemed.', + MerchantDeviceToken: receivedObject.DeviceToken, + MerchantSessionToken: receivedObject.SessionToken, + MerchantAccountID: receivedObject.AccountID, + MerchantClientID: VALID_CLIENT.ClientID, + MerchantDisplayName: VALID_CLIENT.Merchant[0].CompanyAlias, + MerchantSubDisplayName: VALID_CLIENT.Merchant[0].CompanySubName, + MerchantVATNo: VALID_CLIENT.Merchant[0].VATNo, + MerchantImage: VALID_CLIENT.Merchant[0].CompanyLogo, + MerchantInvoice: null, + MerchantComment: receivedObject.MerchantComment, + TransactionStatus: 1, + RequestAmount: receivedObject.RequestAmount, + TipAmount: 0, + StatusInfo: 'Paycode redeemed. Waiting for customer...', + MerchantLocation: { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }, + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }) + ); + }); + it('responds', () => { + return expect(callP).to.deep.equal({ + code: '10020', + info: 'PayCode redeemed.', + TransactionID: '5a0ef9e35a04b54fb0dd352f' + }); + }); + }); + describe('invalid paycode', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + validStub.validateRedeemPayCode.returns({ + code: '174', + message: 'Invalid body.PayCode: should NOT be shorter than 5 characters' + }); + + const testData = { + DeviceToken: DEVICE_TOKEN, + SessionToken: SESSION_TOKEN, + AccountID: ACCOUNTID, + PayCode: INVALID_PAYCODE, + MerchantComment: MERCHANTCOMMENT, + RequestAmount: REQUESTAMOUNT, + RequestTip: REQUESTTIP, + Latitude: LATITUDE, + Longitude: LONGITUDE + }; + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, testData); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('does not removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.not.have.been + .called; + }); + it('does not finds all objects', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.not.have.been + .called; + }); + it('does not updates transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '174', + info: 'Invalid body.PayCode: should NOT be shorter than 5 characters' + }); + }); + }); + + describe('display name too short', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + try { + await redeemPaycodeClass.redeemPaycodeP(SHORT_DISPLAYNAME_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('does not remove paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.not.have.been + .called; + }); + it('does not find any objects', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.not.have.been + .called; + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '474', + info: 'DisplayName is invalid. Please fill out customer details.' + }); + }); + }); + describe('company alias too short', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + try { + await redeemPaycodeClass.redeemPaycodeP(SHORT_COMPANYALIAS_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('does not remove paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.not.have.been + .called; + }); + it('does not find any objects', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.not.have.been + .called; + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '475', + info: 'CompanyAlias is invalid. Please fill out Merchant details.' + }); + }); + }); + describe('non-merchant requesting tip', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + try { + await redeemPaycodeClass.redeemPaycodeP(NOTMERCHANT_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('does not remove paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.not.have.been + .called; + }); + it('does not find any objects', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.not.have.been + .called; + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '476', + info: 'Only Merchants can request a tip.' + }); + }); + }); + describe('database offline - 175', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onFirstCall().rejects({ + code: '175', + info: 'Database offline.'}); + + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('does not remove paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.not.have.been + .called; + }); + it('try\'s find paycode object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '175', + info: 'Database offline.'}); + }); + }); + describe('Failed to find paycode', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onFirstCall().resolves(); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('does not remove paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.not.have.been + .called; + }); + it('fails to find paycode object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledOnce.calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '176', + info: 'Invalid PayCode.' + }); + }); + }); + describe('database offline - 177', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.removeObjectPWithCode + .rejects({ + code: '177', + info: 'Database offline.'}); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('try\'s to remove the paycode object', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds the paycode object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '177', + info: 'Database offline.'}); + }); + }); + describe('database offline - 178', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onSecondCall().rejects({ + code: '178', + info: 'Database offline.'}); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('try\'s to find the transaction object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledTwice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '178', + info: 'Database offline.'}); + }); + }); + describe('Failed to find transaction', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onSecondCall().resolves(); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('fails to find the transaction object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledTwice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '179', + info: 'Invalid TransactionID.' + }); + }); + }); + describe('transaction object has invalid transaction status', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onSecondCall().resolves(INVALID_TRANSACTIONOBJECT); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds the transaction object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledTwice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '179', + info: 'Invalid TransactionID.' + }); + }); + }); + describe('database offline - 229', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onThirdCall().rejects({ + code: '229', + info: 'Database offline.'}); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('try\'s to find Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '229', + info: 'Database offline.'}); + }); + }); + describe('failed to find Merchant Object', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('fails to find Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '276', + info: 'Invalid merchant AccountID.'}); + }); + }); + describe('Merchant Object has no Billing Address', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(NOBILLINGADDRESS_MERCHANTOBJECT); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '491', + info: 'No valid billing address.'}); + }); + }); + describe('database offline - 279', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.updateObjectPWithCode + .rejects({ + code: '279', + info: 'Database offline.'}); + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(DELETED_MERCHANTOBJECT); + + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('try\'s to update Transaction Object', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + TransactionStatus: 14, + StatusInfo: 'Merchant account deleted.', + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }), + sinon.match({upsert: false}), + sinon.match(false), + sinon.match('279') + ); + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '279', + info: 'Database offline.'}); + }); + }); + describe('merchant account ID has been Deleted', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(DELETED_MERCHANTOBJECT); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('updates Transaction Object', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + TransactionStatus: 14, + StatusInfo: 'Merchant account deleted.', + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }), + sinon.match({upsert: false}), + sinon.match(false), + sinon.match('279') + ); + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '275', + info: 'Deleted merchant AccountID.'}); + }); + }); + describe('database offline - 296', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.updateObjectPWithCode + .rejects({ + code: '296', + info: 'Database offline.'}); + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(NOTRECEIVEINGACCOUNT_MERCHANTOBJECT); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('try\'s to update Transaction Object', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + TransactionStatus: 15, + StatusInfo: 'Merchant account is not a receiving account.', + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }), + sinon.match({upsert: false}), + sinon.match(false), + sinon.match('296') + ); + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '296', + info: 'Database offline.'}); + }); + }); + describe('merchant account cannot receive payments', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(NOTRECEIVEINGACCOUNT_MERCHANTOBJECT); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('updates Transaction Object', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + TransactionStatus: 15, + StatusInfo: 'Merchant account is not a receiving account.', + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }), + sinon.match({upsert: false}), + sinon.match(false), + sinon.match('296') + ); + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '297', + info: 'Account cannot receive payment.'}); + }); + }); + describe('invalid image details', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(INVALIDUSERIMAGE_MERCHANTOBJECT); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('does not update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.not.have.been + .called; + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '231', + info: 'Invalid image details.'}); + }); + }); + describe('database error - 175', () => { + /** + * Before each test, set up the tests stubs to return the controlled data, + * then call the function we are testing. + */ + beforeEach(async () => { + mainDBPStub.updateObjectPWithCode + .rejects({ + code: '175', + info: 'Database offline.'}); + mainDBPStub.findOneObjectPWithCode + .onThirdCall().resolves(COMPANYLOGO_MERCHANTOBJECT); + try { + await redeemPaycodeClass.redeemPaycodeP(VALID_CLIENT, receivedObject); + } catch (error) { + pError = error; + } + }); + it('validates paycode', () => { + return expect(validStub.validateRedeemPayCode).to.have.been + .calledOnce; + }); + it('removes paycode', () => { + return expect(mainDBPStub.removeObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('177') + ); + }); + it('finds Merchant Object', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ) + .calledWith( + sinon.match.any, + sinon.match({_id: mongodb.ObjectID(PAYCODEOBJECT.TransactionID)}), + sinon.match(undefined), + sinon.match(false), + sinon.match('178') + ) + .calledWith( + sinon.match.any, + sinon.match({PayCode: receivedObject.PayCode}), + sinon.match(undefined), + sinon.match(false), + sinon.match('175') + ); + }); + it('try\'s to update transaction', () => { + return expect(mainDBPStub.updateObjectPWithCode).to.have.been + .calledOnce + .calledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ + $set: { + PayCodeID: 'Redeemed.', + MerchantDeviceToken: receivedObject.DeviceToken, + MerchantSessionToken: receivedObject.SessionToken, + MerchantAccountID: receivedObject.AccountID, + MerchantClientID: VALID_CLIENT.ClientID, + MerchantDisplayName: VALID_CLIENT.Merchant[0].CompanyAlias, + MerchantSubDisplayName: VALID_CLIENT.Merchant[0].CompanySubName, + MerchantVATNo: VALID_CLIENT.Merchant[0].VATNo, + MerchantImage: VALID_CLIENT.Merchant[0].CompanyLogo, + MerchantInvoice: null, + MerchantComment: receivedObject.MerchantComment, + TransactionStatus: 1, + RequestAmount: receivedObject.RequestAmount, + TipAmount: 0, + StatusInfo: 'Paycode redeemed. Waiting for customer...', + MerchantLocation: { + type: 'Point', + coordinates: [receivedObject.Longitude, receivedObject.Latitude] + }, + LastUpdate: now, + LastVersion: TRANSACTIONOBJECT.LastVersion + 1 + } + }), + sinon.match({upsert: false}), + sinon.match(false), + sinon.match('175') + ); + }); + it('throws', () => { + return expect(pError).to.deep.equal({ + code: '175', + info: 'Database offline.'}); + }); + }); +}); diff --git a/node_server/integration_api/controllers/clients_controller.js b/node_server/integration_api/controllers/clients_controller.js new file mode 100644 index 0000000..e3330e9 --- /dev/null +++ b/node_server/integration_api/controllers/clients_controller.js @@ -0,0 +1,329 @@ +/** + * @fileOverview Controllers for functions related to clients + */ +'use strict'; + +const _ = require('lodash'); +const Q = require('q'); +const debug = require('debug')('integration-api:clients'); +const httpStatus = require('http-status-codes'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const clientUtils = require(global.pathPrefix + '../utils/client/client.js'); +const references = require(global.pathPrefix + '../utils/references.js'); +const responsesUtils = require(global.pathPrefix + '../utils/responses.js'); +const diligence = require(global.pathPrefix + '../utils/diligence/diligence.js'); +const templates = require(global.pathPrefix + '../utils/templates.js'); +const mailer = require(global.pathPrefix + 'mailer.js'); +const formattingUtils = require(global.pathPrefix + '../utils/formatting.js'); + +var promClient = require('prom-client'); +var counters = { + addClient: new promClient.Counter({ + name: 'bridge_server_intapi_addclient_total', + help: 'Count of calls to addClient in the integrations API.', + labelNames: ['result'] + }) +}; + +const DB_ERROR_ADD_CLIENT = 'BRIDGE: DB error adding client'; +const FAILED_ADD_CLIENT = 'BRIDGE: Failed add client.'; +const CLIENT_ALREADY_EXISTS = 'BRIDGE: Client already exists'; +const DB_ERROR_ADD_ADDRESS = 'BRIDGE: DB error adding address'; +const FAILED_ADD_ADDRESS = 'BRIDGE: Failed add address.'; +const CANT_SEND_EMAIL = 'BRIDGE: Cant send marketing email.'; + +module.exports = { + addClient: addClient +}; + +/** + * Handler for the addClient function. + * This performs KYC on the provided information, and on success it adds + * the client to the database. + * + * @param {Object} req - the request object + * @param {Object} res - the response object + */ +function addClient(req, res) { + const body = req.swagger.params.body.value; + const merchant = req.session.data.Merchant; + // + // Create the client object (includes setting of defaults) + // Note that we have NO PASSWORD for a client added through this API. They + // will be sent a "welcome" email to invite them to complete the sign up + // including setting a password. For now they have an empty password and hash, + // and will not be able to login. + // + let client = new clientUtils.Client( + body.email, + '', // No password + '', // No password hash + merchant.ClientID // Use the ClientID as the operator so we can find it later + ); + + // + // Add the feature flag for due diligence as we always need to carry out + // due diligence on clients added through this API. + // + client.FeatureFlags = ['diligence']; + + // + // Create the address object from: + // 1. The data we were passed in + // 2. Specific defaults for values we need + // 3. Blank defaults for everything else + // + let address = _.clone(body.kyc.ResidentialAddress); + _.defaults( + address, + { + ClientID: client.ClientID, + AddressDescription: 'Residential address', + DateAdded: new Date(), + LastUpdate: new Date() + }, + mainDB.blankAddress() + ); + + // + // Create the KYC object. Need to remove the Address object, as it will + // be replaced by AddressID later. + // + let kyc = _.clone(body.kyc); + delete kyc.Address; + + // + // Carry out the steps to have this partial account in place: + // 1. Add the client to the database + // 2. Add the address to the database + // 3. Call client.setKyc() to set the KYC data and do the diligence testing + // 4. Send the response. + // 5. Send the welcome email (TO DO) + + // 1. Add client to database + const clientP = addClientToDb(client); + + // 2. Add address + const addressP = clientP.then(() => addAddressToDb(address)); + + // 3. Set the KYC + const kycP = Q.all([clientP, addressP]) + .spread((savedClient, savedAddress) => setKyc(savedClient, savedAddress, kyc)); + + // 4. Send the response + Q.all([clientP, addressP, kycP]) + .spread((client, address, kycResult) => { + // + // At least partially added this customer, so send an appropriate email + // + sendMarketingWelcomeEmail(client, merchant); + + // + // We may have warnings to respond with + // + const responses = [ + [ + clientUtils.SETKYC_RESPONSES.OK, + httpStatus.OK, 100, 'Client added.' + ], + [ + clientUtils.SETKYC_RESPONSES.WARNING_REFER, + httpStatus.BAD_REQUEST, 101, 'KYC information incomplete' + ], + [ + clientUtils.SETKYC_RESPONSES.WARNING_INTERNAL_CHECKS, + httpStatus.BAD_REQUEST, 102, 'Additional KYC checks required.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, kycResult); + + // + // Update the counter with the type of result + // + counters.addClient.inc( + { + result: kycResult === clientUtils.SETKYC_RESPONSES.OK ? 'success' : 'warn' + }, + 1, + new Date() + ); + }) + .catch((error) => { + debug(' - error updating KYC', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 103, 'Database Offline', true + ], + [ + DB_ERROR_ADD_CLIENT, + httpStatus.BAD_GATEWAY, 104, 'Database Offline adding client' + ], + [ + DB_ERROR_ADD_ADDRESS, + httpStatus.BAD_GATEWAY, 105, 'Database Offline adding address' + ], + [ + CLIENT_ALREADY_EXISTS, + httpStatus.CONFLICT, 106, 'Client already registered with Bridge' + ], + [ + FAILED_ADD_CLIENT, + httpStatus.INTERNAL_SERVER_ERROR, 107, 'Unexpected error adding client' + ], + [ + FAILED_ADD_ADDRESS, + httpStatus.INTERNAL_SERVER_ERROR, 108, 'Unexpected error adding address' + ], + [ + references.ERRORS.INVALID_ADDRESS, + httpStatus.INTERNAL_SERVER_ERROR, 109, 'Address not found', true + ], + [ + diligence.ERRORS.VERIFICATION_FAILED, + httpStatus.BAD_REQUEST, 110, 'Unable to verify KYC', true + ], + [ + clientUtils.SETKYC_ERRORS.DOB_MISMATCH, + httpStatus.INTERNAL_SERVER_ERROR, 111, 'Date of birth mismatch' + ], + [ + clientUtils.SETKYC_ERRORS.UPDATE_FAILED, + httpStatus.INTERNAL_SERVER_ERROR, 112, 'Client not found during update' + ], + [ + clientUtils.SETKYC_ERRORS.INVALID_PARAMETERS, + httpStatus.BAD_REQUEST, 113, 'Invalid parameters' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + + // + // Update the counter with the type of result + // + counters.addClient.inc({result: 'fail'}, 1, new Date()); + }); +} + +/** + * Adds the client object to the database + * + * @param {Object} client - a client object + * @returns {Promise} - Promise for the added client, or reject with error + */ +function addClientToDb(client) { + debug('Adding Client: ', client.ClientName); + const addP = Q.nfcall( + mainDB.addObject, + mainDB.collectionClient, + client, + {}, + true + ); + + return addP + .then((objects) => { + if (objects.length === 0) { + return Q.reject(FAILED_ADD_CLIENT); + } else { + return objects[0]; + } + }) + .catch((err) => { + if (err.code === 11000) { + return Q.reject(CLIENT_ALREADY_EXISTS); + } else { + return Q.reject(DB_ERROR_ADD_CLIENT); + } + }); +} + +/** + * Adds the address object to the database + * + * @param {Object} address - an address object + * @returns {Promise} - Promise for the added client, or reject with error + */ +function addAddressToDb(address) { + debug('Adding Address:', address.Address1); + const addP = Q.nfcall( + mainDB.addObject, + mainDB.collectionAddresses, + address, + {}, + true + ); + + return addP + .then((objects) => { + if (objects.length === 0) { + return Q.reject(FAILED_ADD_ADDRESS); + } else { + return objects[0]; + } + }) + .catch((err) => Q.reject(DB_ERROR_ADD_ADDRESS)); +} + +/** + * Sets the KYC and runs the due diligence on them + * + * @param {Object} client - The client to add the KYC to + * @param {Object} address - The address that's been added (for AddressID) + * @param {Object} kyc - The KYC details to add + * + * @return {Promise} - Promise for the result of setting KYC and diligence + */ +function setKyc(client, address, kyc) { + debug('Setting KYC:', kyc.FirstName); + kyc.ResidentialAddressID = address._id.toString(); + + return clientUtils.setKyc(client, kyc); +} + +/** + * Sends a marketing-style welcome email to try and get the client to complete + * the bridge registration. + * + * @param {Object} client - the client that has just been added + * @param {Object} merchant - the merchant that has just added the client. + * @returns {Promise} - promise for the success of sending the email + */ +function sendMarketingWelcomeEmail(client, merchant) { + debug(' - sending marketing welcome email'); + + const query = { + code: client.EMailValidationToken, + email: client.ClientName + }; + const signUpUrl = formattingUtils.formatPortalUrl('welcome-link', query); + + const data = { + Title: client.KYC[0].Title, + LastName: client.KYC[0].LastName, + merchantName: merchant.Merchant[0].CompanyAlias, + signUpUrl: signUpUrl + }; + var htmlEmail = templates.render('marketing-generic', data); + var subject = 'Welcome to Bridge in partnership with ' + data.merchantName; + + // + // Always send emails + // + var mode = 'Live'; + + return Q.nfcall( + mailer.sendEmail, + mode, + client.ClientName, + subject, + htmlEmail, + 'thoughtful-enterprises-marketing' + ) + .catch(function(error) { + return Q.reject(CANT_SEND_EMAIL); + }); +} diff --git a/node_server/integration_api/controllers/payments_controller.js b/node_server/integration_api/controllers/payments_controller.js new file mode 100644 index 0000000..7f8a2d0 --- /dev/null +++ b/node_server/integration_api/controllers/payments_controller.js @@ -0,0 +1,784 @@ +/* eslint-disable */ +/** + * @fileOverview Controllers for functions related to payments + */ +'use strict'; + +const _ = require('lodash'); +const Q = require('q'); +const debug = require('debug')('integration-api:clients'); +const httpStatus = require('http-status-codes'); + +const config = require(global.configFile); +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const references = require(global.pathPrefix + '../utils/references.js'); +const anon = require(global.pathPrefix + '../utils/anon.js'); +const impl = require(global.pathPrefix + '../impl/confirm_transaction.js'); +const acquirers = require(global.pathPrefix + '../utils/acquirers/acquirer.js'); +const acqErrors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +const responsesUtils = require(global.pathPrefix + '../utils/responses.js'); + +const implRedeem = require(global.pathPrefix + '../impl/redeem_paycode.js'); +const implGetUpdate = require(global.pathPrefix + '../impl/get_transaction_update.js'); + +const promClient = require('prom-client'); + +const counters = { + takePayment: new promClient.Counter({ + name: 'bridge_server_intapi_takepayment_total', + help: 'Count of calls to takePayment in the integrations API.', + labelNames: ['result'] + }) +}; + +module.exports = { + takePayment, + redeemPaycode, + getTransactionUpdate +}; + +const CLIENT_NOT_OWNED = 'BRIDGE: Client not owned by this merchant'; +const FAILED_ADD_ADDRESS = 'BRIDGE: Failed to add billing address'; +const DB_ERROR_ADD_ADDRESS = 'BRIDGE: DB failed when adding billing address'; +const FAILED_ADD_ACCOUNT = 'BRIDGE: Failed to add account'; +const DB_ERROR_ADD_ACCOUNT = 'BRIDGE: DB failed when adding account'; +const FAILED_ADD_TRANSACTION = 'BRIDGE: Failed to add transaction'; +const DB_ERROR_ADD_TRANSACTION = 'BRIDGE: DB failed when adding transaction'; + +/** + * Handler for the takePayment function. + * This processes a direct card payment + * + * @param {Object} req - the request object + * @param {Object} res - the response object + */ +function takePayment(req, res) { + // + // To take a direct payment we need to: + // 1. Check the client was added by the merchant (for security) + // 2. Add the billing Address if different from residential address + // 3. Create a client Account (NOT storing encrypted card PAN) + // 4. Create a Transaction for the payment + // 5. Process payment (passing in decrypted details rather than getting from account) + // + const body = req.swagger.params.body.value; + const merchant = req.session.data.Merchant; + const sessionToken = req.session.data.PseudoSession; + + // + // 1. Find the client, ensuring they were added by this merchant + // + const clientP = findClient(body.email, merchant); + + // + // 2. Add the billing address + // + const addressP = clientP.then((client) => addAddress(client, body.cardDetails.BillingAddress)); + + // + // 3. Add the client account + // + const accountP = Q.all([clientP, addressP]) + .spread((client, address) => addAccount(client, address, body.cardDetails)); + + // + // 4. Add a transaction + // + const transactionP = Q.all([clientP, accountP]) + .spread((client, account) => addTransaction(client, account, merchant, body, sessionToken)); + + // + // 5. Process the transactions + // + const resultP = Q.all([clientP, transactionP]) + .spread((client, transaction) => makePayment(client, transaction, body)); + + // + // Response handling + // + Q.all([clientP, addressP, accountP, transactionP, resultP]).then((results) => { + res.status(httpStatus.OK).json({ + TransactionID: results[3]._id.toString() + }); + counters.takePayment.inc({result: 'success'}, 1, new Date()); + }).catch((error) => { + debug('Error:', error); + + // + // Define the responses + // + const responses = [ + // + // Errors when reading from the database + // + [ + 'MongoError', + httpStatus.INTERNAL_SERVER_ERROR, 510, 'Database Offline', true + ], + + // + // Errors from adding database entries needed for main processing + // + [ + CLIENT_NOT_OWNED, + httpStatus.FORBIDDEN, 999, 'Client not owned by this merchant' + ], + [ + FAILED_ADD_ADDRESS, + httpStatus.INTERNAL_SERVER_ERROR, 999, 'Failed to add billing address' + ], + [ + DB_ERROR_ADD_ADDRESS, + httpStatus.BAD_GATEWAY, 999, 'DB failed when adding billing address' + ], + [ + FAILED_ADD_ACCOUNT, + httpStatus.INTERNAL_SERVER_ERROR, 999, 'Failed to add account' + ], + [ + DB_ERROR_ADD_ACCOUNT, + httpStatus.BAD_GATEWAY, 999, 'DB failed when adding account' + ], + [ + FAILED_ADD_TRANSACTION, + httpStatus.INTERNAL_SERVER_ERROR, 999, 'Failed to add transaction' + ], + [ + DB_ERROR_ADD_TRANSACTION, + httpStatus.BAD_GATEWAY, 999, 'DB failed when adding transaction' + ], + + // + // Errors from the main implementation + // + [ + impl.ERRORS.MERCHANT_NOT_FOUND, + httpStatus.FORBIDDEN, 551, 'Merchant information not found' + ], + [ + impl.ERRORS.CLIENT_DETAILS_NOT_SET, + httpStatus.FORBIDDEN, 552, 'User details not set' + ], + [ + impl.ERRORS.MERCHANT_DETAILS_NOT_SET, + httpStatus.FORBIDDEN, 553, 'Merchant details not set' + ], + [ + impl.ERRORS.CLIENT_KYC_INCOMPLETE, + httpStatus.FORBIDDEN, 554, 'Additional customer information required' + ], + [ + impl.ERRORS.MERCHANT_KYC_INCOMPLETE, + httpStatus.FORBIDDEN, 555, 'Additional merchant information required' + ], + [ + impl.ERRORS.TRANSACTION_TOTAL_TOO_HIGH, + httpStatus.BAD_REQUEST, 310, 'Total above current limit' + ], + [ + impl.ERRORS.TRANSACTION_TOTAL_TOO_LOW, + httpStatus.BAD_REQUEST, 311, 'Total below current limit' + ], + [ + impl.ERRORS.FAILED_SET_CONFIRMED, + httpStatus.BAD_GATEWAY, 510, 'Database offline' + ], + [ + impl.ERRORS.FAILED_SET_COMPLETE, + httpStatus.BAD_GATEWAY, 506, 'Database offline' + ], + [ + impl.ERRORS.FAILED_ADD_HISTORY, + httpStatus.BAD_GATEWAY, 507, 'Database offline' + ], + [ + impl.ERRORS.FAILED_UPDATE_CUSTOMER_BALANCE, + httpStatus.BAD_GATEWAY, 508, 'Database offline' + ], + [ + impl.ERRORS.FAILED_UPDATE_MERCHANT_BALANCE, + httpStatus.BAD_GATEWAY, 509, 'Database offline' + ], + [ + impl.ERRORS.MERCHANT_ACCOUNT_NOT_FOUND, + httpStatus.BAD_REQUEST, 497, 'Invalid Merchant AccountID' + ], + [ + impl.ERRORS.CUSTOMER_ACCOUNT_NOT_FOUND, + httpStatus.INTERNAL_SERVER_ERROR, 494, 'Invalid Customer AccountID' + ], + [ + impl.ERRORS.MERCHANT_ACCOUNT_NOT_RECEIVING, + httpStatus.BAD_REQUEST, 498, 'Not a receiving account' + ], + [ + impl.ERRORS.CUSTOMER_ACCOUNT_NOT_PAYMENTS, + httpStatus.INTERNAL_SERVER_ERROR, 495, 'Not a payments account' + ], + + // + // Errors from the acquirer + // + [ + acqErrors.UNKNOWN_ACQUIRER, + httpStatus.BAD_REQUEST, 532, 'Merchant acquirer unknown', + true + ], + [ + acqErrors.INVALID_COMBINATION, + httpStatus.BAD_REQUEST, 536, 'Invalid payment type', + true + ], + + [ + acqErrors.ACQUIRER_DOWN, + httpStatus.BAD_GATEWAY, 533, 'Cannot connect to acquirer', + true + ], + + [ + acqErrors.INVALID_MERCHANT_NAME, + httpStatus.FORBIDDEN, 534, 'Invalid Merchant account details.', + true + ], + [ + acqErrors.INVALID_MERCHANT_ACCOUNT_DETAILS, + httpStatus.INTERNAL_SERVER_ERROR, 535, 'Receiving account information unreadable', + true + ], + [ + acqErrors.INVALID_CARD_DETAILS, + httpStatus.INTERNAL_SERVER_ERROR, 536, 'Payment account information unreadable', + true + ], + + [ + acqErrors.ACQUIRER_UNKNOWN_ERROR, + httpStatus.INTERNAL_SERVER_ERROR, 537, 'Error processing payment', + true + ], + [ + acqErrors.ACQUIRER_BAD_REQUEST, + httpStatus.INTERNAL_SERVER_ERROR, 538, 'Error processing payment', + true + ], + [ + acqErrors.ACQUIRER_INVALID_PAYMENT_DETAILS, + httpStatus.BAD_REQUEST, 540, 'Invalid payment details', + true + ], + [ + acqErrors.ACQUIRER_UNAUTHORIZED, + httpStatus.BAD_REQUEST, 541, 'Merchant account unauthorized with acquirer', + true + ], + [ + acqErrors.ACQUIRER_MERCHANT_DISABLED, + httpStatus.BAD_REQUEST, 542, 'Merchant account disabled with acquirer', + true + ], + [ + acqErrors.ACQUIRER_INTERNAL_SERVER_ERROR, + httpStatus.BAD_GATEWAY, 543, 'Error processing payment', + true + ], + + [ + acqErrors.CARD_EXPIRED, + httpStatus.FORBIDDEN, 544, 'Card has expired', + true + ], + [ + acqErrors.PAYMENT_FAILED_UNSPECIFIED, + httpStatus.BAD_REQUEST, 545, 'Unspecified error', + true + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + counters.takePayment.inc({result: 'fail'}, 1, new Date()); + }); +} + +/** + * Find the appropriate client and ensure that they have been added by this merchant. + * If we don't find the client at all, we still respond with CLIENT_NOT_OWNED to + * avoid leaking anything about whether the email address existing in the service or not. + * + * @param {String} email - the email address of the client + * @param {Object} merchant - the merchant object + * @returns {Promise} - Promise for the client + */ +function findClient(email, merchant) { + return references.getClientByEmail(email) + .then((client) => { + if (client.OperatorName !== merchant.ClientID) { + return Q.reject(CLIENT_NOT_OWNED); + } + return client; + }) + .catch((err) => Q.reject(CLIENT_NOT_OWNED)); +} + +/** + * Add the billing address. We set the name to "Billing Address" plus a random + * string to ensure that the name is unique. + * + * @param {Object} client - the client to add the address for + * @param {Object} addressInfo - the billing address info from the request + * + * @return {Promise} - a promise for the added address + */ +function addAddress(client, addressInfo) { + const address = _.clone(addressInfo); + _.defaults( + address, + { + ClientID: client.ClientID, + AddressDescription: 'Billing address ' + utils.timeBasedRandomCode(), + DateAdded: new Date(), + LastUpdate: new Date() + }, + mainDB.blankAddress() + ); + + const addP = Q.nfcall( + mainDB.addObject, + mainDB.collectionAddresses, + address, + {}, + true + ); + + return addP + .then((objects) => { + if (objects.length === 0) { + return Q.reject(FAILED_ADD_ADDRESS); + } else { + return objects[0]; + } + }) + .catch((err) => Q.reject(DB_ERROR_ADD_ADDRESS)); +} + +/** + * Adds an account to the database for the transaction that is about to be made. + * Note that we DO NOT store and actual card details as we can't encrypt them + * (as we don't have the device key to do so). + * + * @param {Object} client - the client object + * @param {Object} address - the billing address that was just added + * @param {Object} cardDetails - card details from the request + * @returns {Promise} - promise for the added account + */ +function addAccount(client, address, cardDetails) { + // + // Build the account structure + // + const account = _.defaults( + { + ClientID: client.ClientID, + BillingAddress: address._id.toString(), + NameOnAccount: cardDetails.NameOnAccount, + CardPAN: anon.anonymiseCardPAN(cardDetails.CardPAN), + ClientAccountName: 'Payment details ' + utils.timeBasedRandomCode(), + AccountType: 'Direct Credit/Debit Card Payment', // Custom type for these transaction types + ReceivingAccount: 0, + PaymentsAccount: 1, + /* jshint -W016 */ + AccountStatus: utils.AccountLocked | utils.AccountApiCreated, + /* jshint +W016 */ + LastUpdate: new Date() + }, + mainDB.blankAccount() + ); + + // + // Tokenise the card with Worldpay to get further details. + // Need to add in the unencrypted card details so we can tokenise them + // + const tokeniseDetails = { + NameOnAccount: cardDetails.NameOnAccount, + CardPAN: cardDetails.CardPAN, + CVV: cardDetails.CardCVV, + CardExpiry: cardDetails.ExpiryDate, + + // Optional values are undefined + CardValidFrom: cardDetails.StartDate, + IssueNumber: cardDetails.IssueNumber + }; + + // + // CVV name is different in this request than others, so change it + // + tokeniseDetails.CVV = tokeniseDetails.CardCVV; + delete tokeniseDetails.CardCVV; + + // + // Make the request to tokenise + // + const tokeniseP = acquirers.tokeniseCard(config.verificationProvider, tokeniseDetails) + .then((cardDetails) => { + // + // Add the new details on to the card info + // + return _.assign( + {}, + account, + cardDetails + ); + }); + + // + // Add the account to the database + // + const addP = tokeniseP.then((accountWithDetails) => { + return Q.nfcall( + mainDB.addObject, + mainDB.collectionAccount, + accountWithDetails, + {}, + true + ).then((objects) => { + if (objects.length === 0) { + return Q.reject(FAILED_ADD_ACCOUNT); + } else { + return objects[0]; + } + }).catch((err) => Q.reject(DB_ERROR_ADD_ACCOUNT)); + }); + + return Q.all([tokeniseP, addP]) + .spread((accountWithDetails, addedAccount) => addedAccount); +} + +/** + * Adds the initial transaction to the database. + * This uses a new `TransactionStatus` of PENDING_DIRECT_PAYMENT (30) to + * differentiate these transactions from normal transactions or invoices. + * + * @param {Object} client - the client who is paying + * @param {Object} account - the client account to pay from + * @param {Object} merchant - the merchant to be paid + * @param {Object} body - the request body + * @param {string} sessionToken - a session token for the transaction + * @returns {Promise} - a promise for the intialised transaction + */ +function addTransaction(client, account, merchant, body, sessionToken) { + // + // Build the transaction structure + // + const transaction = _.defaults( + { + CustomerAccountID: account._id.toString(), + CustomerClientID: client.ClientID, + CustomerDisplayName: client.DisplayName, + CustomerImage: 'defaultSelfie', + MerchantDeviceToken: 'IntegrationAPI', + MerchantSessionToken: sessionToken, + MerchantAccountID: body.merchantAccount, + MerchantClientID: merchant.ClientID, + MerchantDisplayName: merchant.Merchant[0].CompanyAlias, + MerchantSubDisplayName: merchant.Merchant[0].CompanySubName, + MerchantImage: merchant.Merchant[0].CompanyLogo, + MerchantVATNo: merchant.Merchant[0].VATNo || '', + TransactionStatus: utils.TransactionStatus.PENDING_DIRECT_PAYMENT, + StatusInfo: 'Transaction for direct payment created', + RequestAmount: body.amount, + LastUpdate: new Date() + }, + mainDB.blankTransaction() + ); + + // + // Add the transaction to the database + // + const addP = Q.nfcall( + mainDB.addObject, + mainDB.collectionTransaction, + transaction, + {}, + true + ); + + return addP + .then((objects) => { + if (objects.length === 0) { + return Q.reject(FAILED_ADD_TRANSACTION); + } else { + return objects[0]; + } + }) + .catch((err) => Q.reject(DB_ERROR_ADD_TRANSACTION)); +} + +/** + * Attempts to make a payment with the provided information. + * + * @param {Object} client - the client who is paying + * @param {Object} transaction - the transaction to be paid + * @param {Object} body - the request body + * @return {Promise} - Promise for the result of confirming the transaction + */ +function makePayment(client, transaction, body) { + // + // Build the data to send. This includes the unencrypted card details from the request + // + const cardDetails = buildCardDetails(body.cardDetails); + + const data = { + TransactionID: transaction._id.toString(), + TipAmount: 0, + initialStatus: utils.TransactionStatus.PENDING_DIRECT_PAYMENT, + cardDetails + }; + + // + // Need a fake Device as the helper function assume we are coming from a device + // + const fakeDevice = mainDB.blankDevice(); + + /** + * Call the base implementation + */ + return impl.confirmTransaction(client, fakeDevice, data); +} + +/** + * This takes the information provided in the request and turns it into the + * card details format that we would otherwise get from utils/encryptions.js::decryptCard() + * + * @param {Object} cardDetails - card details from the request + * @returns {Object} - card details in the required format + */ +function buildCardDetails(cardDetails) { + const result = {}; + + // + // Format optional fields + // + if (_.isString(cardDetails.IssueNumber)) { + result.IssueNumber = parseInt(cardDetails.IssueNumber); + } + + if (_.isString(cardDetails.StartDate)) { + result.startMonth = cardDetails.StartDate.substr(0, 2); + result.startYear = '20' + cardDetails.ExpiryDate.substr(3, 2); + } + + // + // Format required fields. + // + result.expiryMonth = cardDetails.ExpiryDate.substr(0, 2); + result.expiryYear = '20' + cardDetails.ExpiryDate.substr(3, 2); + result.cardNumber = cardDetails.CardPAN; + + return result; +} + +/** + * Handler for the redeeemPaycode function. + * + * @param {Object} req - the request object + * @param {Object} res - the response object + */ +async function redeemPaycode(req, res) { + const body = req.swagger.params.body.value; + const merchant = req.session.data.Merchant; + const sessionToken = req.session.data.PseudoSession; + + // + // Need to build the expected object to match the data in the Apps api: + // @see http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/redeempaycode/ + // + // Note that we don't have a device or session token so we just make them up. + // We also don't have a number of optional fields, so we don't include them + // + const request = { + DeviceToken: 'IntegrationAPI', + SessionToken: sessionToken, + PayCode: body.paycode, + RequestAmount: body.amount, + RequestTip: 0, // No tips through the integration API + AccountID: body.merchantAccount, + + // + // Location not available from the Integration API, so set to null + // + Latitude: null, + Longitude: null + }; + const responses = [ + [ + '474', + httpStatus.FORBIDDEN, 474, 'DisplayName is invalid. Please complete customer details' + ], + [ + '475', + httpStatus.FORBIDDEN, 475, 'CompanyAlias is invalid. Please complete merchant details' + ], + [ + '476', + httpStatus.BAD_REQUEST, 476, 'Only Merchants can request a tip' + ], + [ + '175', + httpStatus.BAD_GATEWAY, 175, 'Database offline' + ], + [ + '176', + httpStatus.BAD_REQUEST, 176, 'Invalid paycode' + ], + [ + '177', + httpStatus.BAD_GATEWAY, 177, 'Database offline' + ], + [ + '178', + httpStatus.BAD_GATEWAY, 178, 'Database offline' + ], + [ + '179', + httpStatus.INTERNAL_SERVER_ERROR, 179, 'Invalid TransactionID' + ], + [ + '229', + httpStatus.BAD_GATEWAY, 229, 'Database offline' + ], + [ + '276', + httpStatus.BAD_REQUEST, 276, 'Invalid merchantAccount' + ], + [ + '491', + httpStatus.FORBIDDEN, 491, 'Invalid billing address for merchantAccount' + ], + [ + '279', + httpStatus.BAD_GATEWAY, 279, 'Database offline' + ], + [ + '275', + httpStatus.BAD_REQUEST, 275, 'Deleted merchantAccount' + ], + [ + '296', + httpStatus.BAD_GATEWAY, 296, 'Database offline' + ], + [ + '297', + httpStatus.BAD_REQUEST, 297, 'Account cannot receive payments' + ], + [ + '231', + httpStatus.FORBIDDEN, 231, 'Invalid account image details' + ], + [ + '180', + httpStatus.BAD_GATEWAY, 180, 'Database offline' + ] + ]; + + // + // Call the implementation + // + try { + res = await implRedeem.redeemPaycodeP(merchant, request); + + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, null); + } catch (error) { + if (error) { + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error.code); + } + } +} + +/** + * Handler for the redeeemPaycode function. + * + * @param {Object} req - the request object + * @param {Object} res - the response object + */ +function getTransactionUpdate(req, res) { + const transactionID = req.swagger.params.TransactionID.value; + const merchant = req.session.data.Merchant; + const sessionToken = req.session.data.PseudoSession; + + // + // Need to build the expected object to match the data in the Apps api: + // @see http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/redeempaycode/ + // + // Note that we don't have a device or session token so we just make them up. + // We also don't have a number of optional fields, so we don't include them + // + const request = { + DeviceToken: 'IntegrationAPI', + SessionToken: sessionToken, + TransactionID: transactionID + }; + + // + // Call the implementation + // + Q.nfcall(implGetUpdate.getTransactionUpdate, request) + .then((result) => { + if (result.code === '10019' || result.code === '10021' || result.code === '10029') { + // Still in progress + res.status(httpStatus.ACCEPTED).json(); + } else if (result.code === '10024') { + // Complete succesfully + res.status(httpStatus.OK).json({ + CustomerDisplayName: result.CustomerDisplayName, + CustomerSubDisplayName: result.CustomerSubDisplayName || undefined, + TotalAmount: result.TotalAmount + }); + } else { + // Other "successes" would be considered errors here (e.g. + // Cancelled, Declined, etc.) So just reject them, and the + // catch will handle them + return Q.reject(result); + } + }) + .catch((error) => { + const responses = [ + + [ + '171', + httpStatus.BAD_GATEWAY, 171, 'Database offline' + ], + [ + '172', + httpStatus.BAD_REQUEST, 172, 'Invalid TransactionID' + ], + [ + '173', + httpStatus.BAD_REQUEST, 173, 'Invalid TransactionID' // Wrong API key + ], + [ + '319', + httpStatus.BAD_GATEWAY, 319, 'Database offline' + ], + [ + '320', + httpStatus.FORBIDDEN, 320, 'Paycode Expired' + ], + [ + '10022', + httpStatus.GONE, 10022, error.info // Covers various errors + ], + [ + '10037', + httpStatus.CONFLICT, 10037, 'Transaction refunded' + ], + [ + '234', + httpStatus.INTERNAL_SERVER_ERROR, 234, 'Invalid TransactionStatus' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error.code); + }); +} diff --git a/node_server/integration_api/int_api_server.js b/node_server/integration_api/int_api_server.js new file mode 100644 index 0000000..94bc76f --- /dev/null +++ b/node_server/integration_api/int_api_server.js @@ -0,0 +1,194 @@ +/* eslint-disable no-process-env */ +/* eslint-disable no-unneeded-ternary */ + +'use strict'; + +/** + * The core page for the configuration and deployment of the API server for + * the Web Console. + * + * The API server is powered by a Swagger API definition: + * @see {@link http://swagger.io} + * + * Express middleware is then used to take the Swagger API definition and + * handle most of the essential but repetitive parts of the API: + * - Connecting routes to handler functions + * - Checking security + * - Validating paramters + * - Validating reponses + * - Managing CORS responses + * + * In development mode there is also middleware to serve interactive API + * documentation and the API doc itself. + */ +const _ = require('lodash'); +const compression = require('compression'); +const morgan = require('morgan'); // Logging middleware by expressjs +const express = require('express'); + +const router = express.Router(); +const swaggerTools = require('swagger-tools'); +const RateLimit = require('express-rate-limit'); + +const config = require(global.configFile); +const security = require('./int_security.js'); + +const errorHandler = require(global.pathPrefix + '../swagger_api/api_error_handler.js'); +const initMorgan = require(global.pathPrefix + '../utils/init_morgan.js'); + +// +// Export the router +// +module.exports = { + init +}; + +// +// Swagger Router configuration +// @see {@link https://github.com/apigee-127/swagger-tools/blob/master/docs/Middleware.md#swagger-router} +// +const swaggerRouterOptions = { + // @member {String} - path to the controllers + controllers: global.rootPath + 'integration_api/controllers', + + // @member {Boolean} - enable autogenerated stubs for dev environment + useStubs: process.env.NODE_ENV === 'development' +}; + +// +// Swagger Validator configuration options +// @see {@link https://github.com/apigee-127/swagger-tools/blob/master/docs/Middleware.md#swagger-validator} +// +const swaggerValidatorOptions = { + // @member{Boolean} - validate responses as well as requests + // swagger stubs don't match the validation entirely, so responses can't + // be validated if they are enabled. + validateResponse: swaggerRouterOptions.useStubs ? true : true +}; + +// +// Load the Swagger API defintion file +// +const swaggerDoc = require('./integration_swagger_def.json'); + +// +// We are going to be used as an express router under /int so remove that from +// the front of the base path in the swagger API definition. If we don't +// remove it we end up with a path of /int/int/v0/... +// +swaggerDoc.basePath = swaggerDoc.basePath.replace('/int', ''); + +/** + * Function to intialise the swagger tools for serving the swagger-based + * integration API. + * + * @returns {Object} - router with middleware included + */ +function init() { + // + // Initialise morgan configuration + // + initMorgan.init(); + + // + // Rate limiting options + // Warning: we must clone the value from config so that when we change the + // keyGenerator etc. it doesn't affect other places using the same + // config. + // + const rateLimitConfig = _.clone(config.rateLimits.api); + rateLimitConfig.keyGenerator = function(req) { + // + // Limit per-token if we have a token. Otherwise limit per ip + // + const token = req.header('authorization'); + if (token) { + return token; + } else { + return req.ip; + } + }; + rateLimitConfig.handler = function(req, res) { + // Always send a JSON response + res.status(rateLimitConfig.statusCode).json({ + code: 30500, + info: 'Rate limit reached. Please wait and try again' + }); + }; + const limiter = new RateLimit(rateLimitConfig); + + // + // Initialize the Swagger middleware from the Swagger API definition. + // This is asynchronous so we need to wait until its done before configuring + // all the express middleware we will use for managing the API + // + swaggerTools.initializeMiddleware(swaggerDoc, (middleware) => { + // + // Compression middleware + // + router.use(compression()); + + // + // Logging middleware + // + router.use(morgan('bridge-combined')); + + // + // Middleware to interpret Swagger resources and attach metadata to request + // - must be first in swagger - tools middleware chain + // + router.use(middleware.swaggerMetadata()); + + /* + * Rate Limiting + */ + router.use(limiter); + + // + // Middleware to enforce the security rules definedin the Swagger file. + // Ignore lack of camel case for the swagger defines: + // jshint -W106 + router.use(middleware.swaggerSecurity({ + bearer: security.bearer + })); + + // + // Middleware to validate Swagger request and response parameters + // + router.use(middleware.swaggerValidator(swaggerValidatorOptions)); + + // + // Middleware to route validated requests to the appropriate controller + // + router.use(middleware.swaggerRouter(swaggerRouterOptions)); + + // + // Middleware to serve the Swagger documents and Swagger UI. + // This provides access to the Swagger UI at /api/docs and the full + // swagger json file at /api/api-docs + // Note: only enabled in development environments + // + if (process.env.NODE_ENV === 'development') { + router.use(middleware.swaggerUi()); + } + + // + // Error handler middleware to correct server errors as JSON if needed + // + router.use(errorHandler.errorHandlerMiddleware); + + // + // Stop any requests that didn't get handled above going any further. + // This only applies to requests under this router, so no other part of + // server could handle it. + // + router.use((req, res) => { + res.status(404).json({ + code: 30000, + info: 'API path not found' + }); + }); + }); + + return router; +} diff --git a/node_server/integration_api/int_security.js b/node_server/integration_api/int_security.js new file mode 100644 index 0000000..9c9a1f3 --- /dev/null +++ b/node_server/integration_api/int_security.js @@ -0,0 +1,94 @@ +/** + * @fileOverview Security handler functions for the integrations API + */ + +'use strict'; +const debug = require('debug')('integration-api:security'); +const config = require(global.configFile); +const utils = require(global.pathPrefix + 'utils.js'); +const tokenUtils = require(global.pathPrefix + '../utils/tokens.js'); +const hashingUtils = require(global.pathPrefix + '../utils/hashing.js'); + +module.exports = { + bearer: bearer +}; + +/** + * Handler for the `bearer` security type. It checks the bearer token is valid, + * and if so it fills in a `req.session` object with relevant session information. + * + * + * @param {Object} req - the express request + * @param {Object} def - the swagger security definition + * @param {string} scopes - the value of the Authorization header + * @param {function(error, v)} callback - Result callback + */ +function bearer(req, def, scopes, callback) { + debug('bridgeSession credentials verification'); + // + // Check that there exists at least some value for X-XSRF-TOKEN + // + if (!scopes || scopes.indexOf('Bearer ') !== 0) { + debug('- no credentials supplied'); + reportError(callback); + return; + } + + // + // Validate the token + // + const token = scopes.substr(7); // Remove the `Bearer ` from the front + const tokenP = tokenUtils.validateToken(token).then( + function onSuccess(result) { + // + // Make a pseudo-session token out of our token. We do this + // by hashing the token, with the Client's _id as salt, then cropping + // the result to our token length. We crop from the end of the string + // to avoid the :: at the start + // + let hashP = hashingUtils.regenerateHash( + +config.passwordCryptoVersion, + result.decoded.token, + result.client._id.toString() + ).then((hash) => hash.slice(-1 * utils.tokenLength)); + + // + // Store the Merchant's client in the session for the controllers to + // access. + // + hashP.then((hash) => { + req.session = { + data: { + PseudoSession: hash, + Merchant: result.client + } + }; + callback(); + }).catch((err) => { + // + // Some error in generating the hash. Just use the default error + // + reportError(callback); + }); + }, + function onError(error) { + // + // Don't differentiate sources of error for security reasons + // + reportError(callback); + } + ); +} + +// +// Function to return a consistent error response for failures to authenticate. +// This function is deliberately light on details so as not to leak extra +// information. +// +// @param {securityCallback} callback - The callback to use for responses +// +function reportError(callback) { + var error = new Error('Not authorised'); + error.statusCode = 401; + return callback(error); +} diff --git a/node_server/integration_api/integration_swagger_def.json b/node_server/integration_api/integration_swagger_def.json new file mode 100644 index 0000000..b2e1950 --- /dev/null +++ b/node_server/integration_api/integration_swagger_def.json @@ -0,0 +1,680 @@ +{ + "swagger": "2.0", + "info": { + "version": "1.4", + "title": "Comcarde Bridge Integration API Definition", + "description": "The REST Integration API that allows a subset of system functions to be driven by third party systems. Please contact Comcard for more details and access to the system." + }, + "basePath": "/int/v1", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "security": [ + { + "bearer": [] + } + ], + "tags": [ + { + "name": "general", + "description": "Functions in the API" + }, + { + "name": "payment", + "description": "Functions related to taking payment in various manners" + } + ], + "securityDefinitions": { + "bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "Bearer token for the specific integration partner. The bearer token **MUST** be kept secure as it provides access to the controlled functionality. The token should be sent in the `\"Authorization\"` header as `\"Bearer \"` following https://tools.ietf.org/html/rfc6750#section-2.1[Section 2.1 of RFC 6750]. Contact Comcarde to request a token for use with this API." + } + }, + "responses": { + "GeneralError": { + "description": "General error response format", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + } + }, + "paths": { + "/test": { + "x-swagger-router-controller": "test_controller", + "get": { + "summary": "Test function", + "description": "Tests that communication with the API works, and the supplied bearer token is valid", + "tags": [ + "general" + ], + "operationId": "test", + "responses": { + "default": { + "$ref": "#/responses/GeneralError" + }, + "200": { + "description": "Successful request: bearer token is valid", + "schema": {} + }, + "401": { + "description": "Invalid token", + "schema": { + "$ref": "#/definitions/ErrorInfo" + } + } + } + } + }, + "/clients": { + "x-swagger-router-controller": "clients_controller", + "post": { + "summary": "Add a new client", + "description": "Adds a new client to the system, validating their identity", + "tags": [ + "general" + ], + "operationId": "addClient", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/addClientBody" + } + } + ], + "responses": { + "default": { + "$ref": "#/responses/GeneralError" + }, + "200": { + "description": "Successful request. Calls to send payment details can now be sent, using the email address as an identifier.", + "schema": { + "type":"object", + "description": "TransactionID for the successful transaction", + "properties": { + "TransactionID": { + "$ref": "#/definitions/uuid" + } + } + } + } + } + } + }, + "/payments": { + "x-swagger-router-controller": "payments_controller", + "post": { + "summary": "Take a credit/debit card payment", + "description": "Takes a credit or debit card payment from a client that has previously been added to the system.", + "tags": [ + "payment" + ], + "operationId": "takePayment", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/takeCardPaymentBody" + } + } + ], + "responses": { + "default": { + "$ref": "#/responses/GeneralError" + }, + "200": { + "description": "Payment complete.", + "schema": {} + } + } + } + }, + "/payments/paycode": { + "x-swagger-router-controller": "payments_controller", + "post": { + "summary": "Redeem a Bridge paycode payment", + "description": "Redeems a Bridge paycode and progresses the payment process. Poll GET /transactions/{TransactionID}/status to wait for the customer to confirm.", + "tags": [ + "payment" + ], + "operationId": "redeemPaycode", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redeemPaycodeBody" + } + } + ], + "responses": { + "default": { + "$ref": "#/responses/GeneralError" + }, + "202": { + "description": "Paycode redeemed, and awaiting customer confirmation.", + "schema": { + "$ref": "#/definitions/redeemPaycodeResponse" + } + } + } + } + }, + "/transactions/{TransactionID}/status": { + "x-swagger-router-controller": "payments_controller", + "get": { + "summary": "Checks the status of the transaction", + "description": "Poll at most 1/s to wait for the customer to confirm the transaction.", + "tags": [ + "payment" + ], + "operationId": "getTransactionUpdate", + "parameters": [ + { + "$ref": "#/parameters/TransactionID" + } + ], + "responses": { + "default": { + "$ref": "#/responses/GeneralError" + }, + "200": { + "description": "Transactions completed successfully.", + "schema": { + "$ref": "#/definitions/paycodeTransactionCompleteResponse" + } + }, + "202": { + "description": "Still waiting for customer to confirm the transaction.", + "schema": {} + }, + "404": { + "description": "Transaction can't be found or isn't associated with this merchant", + "schema": { + "$ref": "#/responses/GeneralError" + } + }, + "409": { + "description": "Transaction has been refunded.", + "schema": { + "$ref": "#/responses/GeneralError" + } + }, + "410": { + "description": "Transaction failed or cancelled by customer.", + "schema": { + "$ref": "#/responses/GeneralError" + } + } + } + } + } + }, + "parameters": { + "TransactionID": { + "name": "TransactionID", + "description": "TransactionID as returned from POST /payments/paycode etc.", + "in": "path", + "required": true, + "type": "string", + "pattern": "^([a-f0-9]{24})$", + "minLength": 24, + "maxLength": 24 + } + }, + "definitions": { + "alphaSpace": { + "description": "Text with only ASCII letters and space", + "type": "string", + "pattern": "^([A-Za-z ]*)$", + "x-invalid-pattern": "[^A-Za-z ]", + "example": "Some Text With Only Ascii Letters plus space" + }, + "email": { + "title": "Email address", + "description": "Email address with simplified rules for correctness.", + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "x-invalid-pattern": "[^a-zA-Z0-9.%+-.@]", + "minLength": 7, + "maxLength": 254, + "example": "janedoe@example.com" + }, + "cardDate": { + "title": "Date on a credit/debit card", + "description": "The date on a credit or debit card in MM-YY format", + "type": "string", + "pattern": "^(?:0[1-9]|1[0-2])-[0-9][0-9]$", + "example": "01-70" + }, + "generalTextSpace": { + "description": "General text format + special chars + space", + "type": "string", + "pattern": "^([A-Za-z0-9'[\\]()@?!\\-/.,_&*:;+= ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9'[\\]()@?!\\-/.,_&*:;+= ]", + "example": "Some Text With Spaces plus '&', '*', etc." + }, + "kycGender": { + "description": "The gender as required by the ID verification/AML service.", + "type": "string", + "enum": [ + "M", + "F" + ], + "example": "F" + }, + "paycodeString": { + "description": "Paycode string. 0-9 + A-Z except IOQ which could be confusing", + "type": "string", + "pattern": "^([0-9ABCDEFGHJKLMNPRSTUVWXYZ]*)$", + "minLength": 5, + "maxLength": 12, + "x-invalid-pattern": "[^0-9ABCDEFGHJKLMNPRSTUVWXYZ]", + "example": "A1A1A" + }, + "phoneNumber": { + "description": "UK phone number", + "type": "string", + "pattern": "^\\+[0-9]*$", + "x-invalid-pattern": "[^0-9+]", + "minLength": 8, + "maxLength": 35, + "example": "+447700900000" + }, + "postcode": { + "description": "A UK postcode", + "type": "string", + "pattern": "^[A-Z]{1,2}\\d{1,2}[A-Z]? ?\\d[A-Z]{2}$", + "example": "EH54 7GA" + }, + "uuid": { + "description": "ID of another item in the system", + "type": "string", + "pattern": "^([a-f0-9]{24})$", + "x-invalid-pattern": "[^a-f0-9]", + "minLength": 24, + "maxLength": 24, + "example": "12a345b67c8901234d567e89" + }, + "addClientBody": { + "type":"object", + "description": "Parameters required to add a client to the system", + "properties": { + "email": { + "$ref": "#/definitions/email", + "example": "johndoe@example.com" + }, + "kyc": { + "$ref": "#/definitions/kyc" + } + }, + "required": [ + "email", "kyc" + ] + }, + "takeCardPaymentBody": { + "type": "object", + "description": "Parameters required to take a card payment.", + "properties": { + "merchantAccount": { + "description": "The ID for the merchant account you want the card to pay into", + "$ref": "#/definitions/uuid" + }, + "amount": { + "description": "The amount of the payment IN PENCE. i.e. £123.45 would be sent as `12345`.", + "type": "integer", + "minimum": 1 + }, + "email": { + "description": "The customer's email address (as added previously). This MUST be a client that was added by the same integration, and will not process a payment for any other clients.", + "$ref": "#/definitions/email", + "example": "johndoe@example.com" + }, + "cardDetails": { + "description": "The details for the credit or debit card for the transaction.", + "$ref": "#/definitions/cardDetails" + } + }, + "required": [ + "merchantAccount", + "amount", + "email", + "cardDetails" + ] + }, + "redeemPaycodeBody": { + "type": "object", + "description": "Parameters required to take a payment using a Bridge paycode.", + "properties": { + "merchantAccount": { + "description": "The ID for the merchant account you want to pay into", + "$ref": "#/definitions/uuid" + }, + "amount": { + "description": "The amount of the payment IN PENCE. i.e. £123.45 would be sent as `12345`.", + "type": "integer", + "minimum": 1 + }, + "paycode": { + "description": "The paycode to redeem.", + "$ref": "#/definitions/paycodeString" + } + }, + "required": [ + "merchantAccount", + "amount", + "paycode" + ] + }, + "redeemPaycodeResponse": { + "type": "object", + "description": "Response to the request to redeem a paycode. Contains the TransactionID needed to poll for status updates", + "properties": { + "TransactionID": { + "description": "The ID for the transaction that is pending customer confirmation", + "$ref": "#/definitions/uuid" + } + }, + "required": [ + "TransactionID" + ] + }, + "paycodeTransactionCompleteResponse": { + "type": "object", + "description": "Transaction has completed successfully.", + "properties": { + "CustomerDisplayName": { + "description": "The display name of the customer", + "allOf": [ + { + "$ref": "#/definitions/alphaSpace" + }, + { + "minLength": 2, + "maxLength": 101 + } + ] + }, + "CustomerSubDisplayName": { + "description": "The sub-display name of the customer", + "allOf": [ + { + "$ref": "#/definitions/alphaSpace" + }, + { + "minLength": 2, + "maxLength": 101 + } + ] + }, + "TotalAmount": { + "description": "The amount of the payment IN PENCE. i.e. £123.45 would be sent as `12345`.", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "CustomerDisplayName", + "TotalAmount" + ] + }, + "kyc": { + "type": "object", + "description": "Know Your Customer (KYC) data", + "properties": { + "Title": { + "description": "Client's title (Mr, Mrs, Ms, Dr, etc.", + "allOf": [ + { + "$ref": "#/definitions/alphaSpace" + }, + { + "minLength": 2, + "maxLength": 20 + } + ], + "example": "Mr" + }, + "FirstName": { + "allOf": [ + { + "$ref": "#/definitions/alphaSpace" + }, + { + "minLength": 2, + "maxLength": 50 + } + ], + "example": "John" + }, + "LastName": { + "allOf": [ + { + "$ref": "#/definitions/alphaSpace" + }, + { + "minLength": 2, + "maxLength": 50 + } + ], + "example": "Doe" + }, + "MiddleNames": { + "allOf": [ + { + "$ref": "#/definitions/alphaSpace" + }, + { + "minLength": 0, + "maxLength": 50 + } + ] + }, + "DateOfBirth": { + "description": "Date of birth as an ISO8601 full-date (YYYY-MM-DD)", + "type": "string", + "format": "date", + "example": "1970-01-01" + }, + "ResidentialAddress": { + "$ref": "#/definitions/address", + "description": "The customer's residential address. The accuracy of the address format is critical to succesful due diligence of the customer and SHOULD be filled out from a postcode-driven address lookup service or similar for best results." + }, + "Gender": { + "$ref": "#/definitions/kycGender", + "example": "M" + } + }, + "required": [ + "Title", + "FirstName", + "LastName", + "DateOfBirth", + "ResidentialAddress", + "Gender" + ] + }, + "address": { + "type": "object", + "description": "A UK address", + "properties": { + "BuildingNameFlat": { + "description": "Building name or flat number", + "allOf": [ + { + "$ref": "#/definitions/generalTextSpace" + }, + { + "minLength": 1, + "maxLength": 64 + } + ], + "example": "Flat 20" + }, + "Address1": { + "description": "First line of address", + "allOf": [ + { + "$ref": "#/definitions/generalTextSpace" + }, + { + "minLength": 4, + "maxLength": 64 + } + ], + "example": "Victoria House" + }, + "Address2": { + "description": "Second line of address", + "allOf": [ + { + "$ref": "#/definitions/generalTextSpace" + }, + { + "minLength": 4, + "maxLength": 64 + } + ], + "example": "15 The Street" + }, + "Town": { + "description": "Postal Town", + "allOf": [ + { + "$ref": "#/definitions/generalTextSpace" + }, + { + "minLength": 3, + "maxLength": 32 + } + ], + "example": "Christchurch" + }, + "PostCode": { + "description": "Post code", + "allOf": [ + { + "$ref": "#/definitions/postcode" + }, + { + "minLength": 3, + "maxLength": 32 + } + ], + "example": "BH23 6AA" + }, + "Country": { + "description": "Country. Only open to UK residents at present", + "type": "string", + "enum": [ + "United Kingdom" + ], + "example": "United Kingdom" + }, + "PhoneNumber": { + "description": "A contact phone number at this address; ideally a land line", + "allOf": [ + { + "$ref": "#/definitions/phoneNumber" + }, + { + "minLength": 8, + "maxLength": 35 + } + ], + "example": "+441214960711" + } + }, + "required": [ + "Address1", "Town", "PostCode", "Country", "PhoneNumber" + ] + }, + "cardDetails": { + "description": "Card details neccessary to process a payment", + "type":"object", + "properties": { + "NameOnAccount": { + "description": "The name on the customer's account", + "allOf": [ + { + "$ref": "#/definitions/alphaSpace" + }, + { + "minLength": 5, + "maxLength": 64 + } + ], + "example": "John Doe" + }, + "CardPAN": { + "description": "The long number on the front of the card (with all spaces removed).", + "type": "string", + "minLength": 8, + "maxLength": 19, + "pattern": "^[0-9]*$", + "example": "1234567890123456" + }, + "CardCVV": { + "description": "The CVV/CVC/CV2 number. Usually found on the back of the card", + "type": "string", + "pattern": "^[0-9]{3,4}$", + "example": "123" + }, + "ExpiryDate": { + "$ref": "#/definitions/cardDate", + "example": "01-70" + }, + "StartDate": { + "$ref": "#/definitions/cardDate", + "example": "01-70" + }, + "IssueNumber": { + "description": "Issue number on the card. Only applies to some debit cards", + "type": "integer", + "minimum": 0, + "example": 1 + }, + "BillingAddress": { + "description": "The billing address to use for the card, if the billing address does not match the residential address", + "$ref": "#/definitions/address" + } + }, + "required": [ + "NameOnAccount", "CardPAN", "CardCVV", "ExpiryDate", "BillingAddress" + ] + }, + "ErrorInfo": { + "description": "More information on the error reason", + "type": "object", + "properties": { + "code": { + "description": "Error code", + "type": "integer", + "example": -1 + }, + "info": { + "description": "Text description of the issue", + "type": "string", + "example": "Unknown Error" + } + }, + "example": { + "code": "1", + "description": "Some error" + } + } + } +} \ No newline at end of file diff --git a/node_server/node_server.js b/node_server/node_server.js new file mode 100644 index 0000000..8239373 --- /dev/null +++ b/node_server/node_server.js @@ -0,0 +1,948 @@ +/** + * @fileOverview Node.js Bridge Server Application for Bridge Pay + * @preserve Copyright 2017 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + */ +/* eslint-disable no-process-env, no-process-exit, no-console, import/max-dependencies */ + +/** + * Requirements. + */ +const path = require('path'); +const exitCodes = require('./exitcodes.js'); +const logUtils = require('./utils/logging'); + +const logger = logUtils(__filename, 'bridge:server'); + +/** + * Environment defines. Use to change endpoints automatically in other sections of the code. + * This is global + */ +global.DEPLOYMENT_ENVS = ['AWS', 'Bluemix', 'Azure', 'Flexiion', 'Local']; +global.CURRENT_DEPLOYMENT_ENV = 'Azure'; // Sets the default environment. + +/** + * Parse the command line using minimist to find command line parameters. + * Valid command line parameters are: + *
+ *
--path
Prefix for the javascript modules path. Defaults to /node_server/ComServe/
+ *
--config
Path to the config file. Defaults to /ComServe/config.js
+ *
--env
Deployment environment switch to change end points. Defaults to 'Azure'
+ *
+ * @type {object} + */ +const opts = { + string: ['path', 'config', 'env'], + alias: { + path: ['p'], + env: ['e'] + } +}; + +/** + * Use the opts above to parse the command line, and store the parameters in argv + * @type object + */ +const argv = require('minimist')(process.argv.slice(2), opts); + +/** + * If the env property is present, change the environment to the requested one. This is taken from the command line. + * Most of the environmental differences are endpoints. + */ +if (argv.hasOwnProperty('env')) { + if (global.DEPLOYMENT_ENVS.indexOf(argv.env) < 0) { + console.log('\nBad deployment environment. Options are: ' + JSON.stringify(global.DEPLOYMENT_ENVS)); + process.exit(exitCodes.EXIT_CODE_NO_ENVIRONMENT); + } else { + global.CURRENT_DEPLOYMENT_ENV = argv.env; + } +} + +/** + * Change default paths based on environment. + * Note that adding the command line switch overrides everything else. + */ +if (argv.hasOwnProperty('path')) { + global.rootPath = argv.path; +} else { + switch (global.CURRENT_DEPLOYMENT_ENV) { + case 'Azure': + global.rootPath = '/home/comcardeadmin/node_server/'; + break; + case 'Bluemix': + global.rootPath = '/node_server/'; + break; + case 'Flexiion': + global.rootPath = '/home/flexops/node_server/'; + break; + default: + global.rootPath = path.join(__dirname, '/'); // Expected to end with a '/' + } +} + +/** + * Store the command line parameters. This will also normalise the path to the OS. + */ +global.pathPrefix = global.rootPath + 'ComServe/'; +if (argv.hasOwnProperty('config')) { + global.configFile = argv.config; +} else { + global.configFile = global.rootPath + 'ComServe/config.js'; +} +global.rootPath = path.normalize(global.rootPath); +global.pathPrefix = path.normalize(global.pathPrefix); +global.configFile = path.normalize(global.configFile); + +/** + * Log what startup params we are using. + */ +console.log('\nLoading Bridge Node Server config files...'); +console.log('Source Path Prefix:', global.pathPrefix); +console.log('ConfigFile:', global.configFile); + +/** + * Load the basic files. Always config first. + */ +let config; +let utils; +try { + // eslint-disable-next-line global-require + config = require(global.configFile); + // eslint-disable-next-line global-require + utils = require(global.pathPrefix + 'utils.js'); +} catch (error) { + console.log('Unable to load configuration files: ' + error); + process.exit(exitCodes.EXIT_CODE_CONFIG_FILE_ERROR); +} + +/** + * Print server information. + */ +console.log(utils.CarriageReturn + 'COMCARDE BRIDGE NODE SERVER (' + config.CCServerName + ', VIP ' + config.CCServerIP + ')'); +console.log(global.CURRENT_DEPLOYMENT_ENV + ' Deployment (' + config.CCServerGroup + ', UUID ' + config.CCUUID + ')'); +console.log('Config: https://' + config.CCWebsiteAddress + ', ' + config.CCServerReleaseType + ' V' + config.CCServerVersion + + ' (Node: ' + config.ServerCommit + ', Portal: ' + config.PortalCommit + ').'); + +/** + * Load the rest of the include files. + */ +const http = require('http'); +const fs = require('fs'); +const express = require('express'); +const helmet = require('helmet'); +const async = require('async'); +const url = require('url'); +const querystring = require('querystring'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const log = require(global.pathPrefix + 'log.js'); +const sms = require(global.pathPrefix + 'sms.js'); +const hJSON = require(global.pathPrefix + 'hJSON.js'); +const credorax = require(global.pathPrefix + 'credorax.js'); +const worldpay = require(global.pathPrefix + 'worldpay.js'); +const rateLimit = require(global.pathPrefix + 'rate_limit.js'); +const migrations = require(global.pathPrefix + 'migrations.js'); + +/** + * Load default images. + */ +try { + let inputImage; + inputImage = fs.readFileSync(global.rootPath + 'WebApp/defaultSelfie.png'); + config.defaultSelfieData = Buffer.from(inputImage).toString('base64'); + inputImage = fs.readFileSync(global.rootPath + 'WebApp/defaultCompanyLogo0.png'); + config.defaultCompanyLogo0Data = Buffer.from(inputImage).toString('base64'); + console.log('Default image data loaded for \'defaultSelfie\' and \'CompanyLogo0\''); +} catch (error) { + console.log('Unable to load default images: ' + error); + process.exit(exitCodes.EXIT_CODE_NO_DEFAULT_IMAGES); +} + +/** + * Note whether verbose mode is on or off. + */ +if (log.verbose) { + console.log('Verbose mode is on. Events, warnings and errors will be written to stdout.' + utils.CarriageReturn); +} else { + console.log('Verbose mode is off. Only errors will be written to stdout.' + utils.CarriageReturn); +} + +/** + * System state defines and web server configuration. Since offload to load balancer, the code only uses HTTP. + * The SSL certificate has been offloaded to the balancer for performance reasons. + */ +const startupServices = { + httpOnline: 1 +}; + +/** + * Load and pre-compile the templates + */ +const templates = require(global.pathPrefix + '../utils/templates.js'); +templates.initTemplates(); + +/** + * Web server defines. + */ +const verboseWebServer = 1; // Additional web server logging for debug. +const serverHTTPport = config.serverHttpPort; // Will be redirected to HTTPS. + +const rootServerDirectory = global.rootPath + 'WebApp'; +const rootPortalDirectory = global.rootPath + 'portal/'; +let filesServed = 0; +const longTickTime = 15 * 12; // Change this value for the long tick return. Multiply by 5 seconds for real time. +let longTick = longTickTime; + +/** + * Define the mongodb configuration parameters. + */ +let cert; +let key; +let mongoConnectOptions = {}; +if (config.mongoUseSSL) { + const certPath = path.join( + __dirname, + config.mongoCACertBase64 + ); + cert = fs.readFileSync(certPath); + key = fs.readFileSync(certPath); + mongoConnectOptions = { + ssl: true, + sslKey: key, + sslCert: cert + }; +} + +/** + * Connect to the mongodb server and open the collections. + * Function takes no parameters. + */ +function startupDatabase() { + // eslint-disable-next-line no-negated-condition + if (!mainDB.dbOnline) { + try { + /** + * Attempt to open the database connection. + */ + mainDB.MClient.connect( + mainDB.dbAddress, + mongoConnectOptions, + (err, db) => { + if (err) { + if (!utils.systemState.dbWaiting) { + log.system( + 'CRITICAL', + ('Could not connect to primary database. ' + JSON.stringify(err) + ' Retrying every 5 seconds...'), + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + utils.systemState.dbWaiting = 1; + } + return; + } + + /** + * Connect to collections. + */ + mainDB.mdb = db; + if (mainDB.mdb) { + mainDB.collectionAccount = mainDB.mdb.collection(mainDB.dbAccount); + mainDB.collectionAccountArchive = mainDB.mdb.collection(mainDB.dbAccountArchive); + mainDB.collectionPaymentInstrument = mainDB.mdb.collection(mainDB.dbPaymentInstrument); + mainDB.collectionPaymentInstrumentArchive = mainDB.mdb.collection(mainDB.dbPaymentInstrumentArchive); + mainDB.collectionAddresses = mainDB.mdb.collection(mainDB.dbAddresses); + mainDB.collectionAddressArchive = mainDB.mdb.collection(mainDB.dbAddressArchive); + mainDB.collectionBridgeLogin = mainDB.mdb.collection(mainDB.dbBridgeLogin); + mainDB.collectionClient = mainDB.mdb.collection(mainDB.dbClient); + mainDB.collectionClientArchive = mainDB.mdb.collection(mainDB.dbClientArchive); + mainDB.collectionDevice = mainDB.mdb.collection(mainDB.dbDevice); + mainDB.collectionDeviceArchive = mainDB.mdb.collection(mainDB.dbDeviceArchive); + mainDB.collectionImages = mainDB.mdb.collection(mainDB.dbImages); + mainDB.collectionItems = mainDB.mdb.collection(mainDB.dbItems); + mainDB.collectionMessages = mainDB.mdb.collection(mainDB.dbMessages); + mainDB.collectionMessagesArchive = mainDB.mdb.collection(mainDB.dbMessagesArchive); + mainDB.collectionPayCode = mainDB.mdb.collection(mainDB.dbPayCode); + mainDB.collectionSystemLog = mainDB.mdb.collection(mainDB.dbLog); + mainDB.collectionTransaction = mainDB.mdb.collection(mainDB.dbTransaction); + mainDB.collectionTransactionArchive = mainDB.mdb.collection(mainDB.dbTransactionArchive); + mainDB.collectionTransactionHistory = mainDB.mdb.collection(mainDB.dbTransactionHistory); + mainDB.collectionTwoFARequests = mainDB.mdb.collection(mainDB.dbTwoFARequests); + mainDB.collectionActivityLog = mainDB.mdb.collection(mainDB.dbActivityLog); + + /** + * Test the database connection. Set up a test log entry. + */ + const logData = {}; + logData.DateTime = new Date(); + logData.ServerID = config.CCServerName + ' (VIP ' + config.CCServerIP + ')'; + logData.Class = 'STARTUP'; + logData.Function = 'node_server.startupDatabase'; + logData.Code = ''; + logData.Info = 'SERVER ONLINE: ' + config.CCServerName + ', Config: https://' + config.CCWebsiteAddress + ', ' + + config.CCServerReleaseType + ' V' + config.CCServerVersion + ' (Node: ' + config.ServerCommit + ', Portal: ' + + config.PortalCommit + ').'; + logData.User = 'System'; + logData.Source = '127.0.0.1'; + + /** + * Write the new log entry. + */ + mainDB.dbOnline = 1; + utils.systemState.dbWaiting = 0; + // eslint-disable-next-line no-shadow + mainDB.addObject(mainDB.collectionSystemLog, logData, undefined, false, (err) => { + if ((err) || (mainDB.dbOnline === 0)) { // Unable to store info. + log.system( + 'CRITICAL', + 'Database connection test failed. Will retry in 5 seconds.', + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + return; + } + + /** + * Success. Database online. + */ + log.system( + 'STARTUP', + ('Connected to requested primary database ' + config.mongoDBAddress), + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + + /** + * Update the logger utils to connect to this db instance + */ + logUtils.init.initMongoTransport(db); + logger.info( + {}, // No request + 'Connected to requested primary database', + { + dbAddress: config.mongoDBAddress + } + ); + + /** + * Scan the database if required - this runs in the background using a cursor. + * It is suggested that this is run for deployment of new versions then subsequently disabled. + */ + if (config.databaseUpdate) { + mainDB.updateDatabase(); + } + if (config.migrateEmailToID) { + migrations.migrateClientNameToID(); + } + + /** + * Get the current number of text messages that are left. + */ + const tempSMSTestMode = sms.smsTestMode; + sms.smsTestMode = true; + sms.sendSMS(null, sms.adminMobile, (config.CCServerName + ' startup complete.'), + // eslint-disable-next-line no-shadow + (err, smsBalance) => { + if (err) { + sms.smsTestMode = tempSMSTestMode; + log.system( + 'CRITICAL', + ('Cannot send SMS or connect to SMS server. ' + err), + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + return; + } + sms.smsTestMode = tempSMSTestMode; + if (50 < smsBalance) { + log.system( + 'STARTUP', + ('Successfully connected to TextLocal (SMS balance is ' + + smsBalance + ').'), + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + } else { + log.system( + 'WARNING', + ('Successfully connected to TextLocal but balance is low (' + + smsBalance + ' remaining).'), + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + } + }); + }); + } else { // Error connecting to collections. Force shutdown. + log.system( + 'CRITICAL', + 'Could not open collections. Please contact the administrator to ensure they are set up.', + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + utils.systemState.shutdownTick = 2; + } + }); + } catch (error) { + log.system( + 'WARNING', + ('Database still attempting to connect. ' + error), + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + } + } else { + log.system( + 'ERROR', + 'Erroneous call to startupDatabase() ignored as database is already online.', + 'node_server.startupDatabase', + '', + 'System', + '127.0.0.1'); + } +} + +/** + * First fast timeout to start up the system. + */ +setTimeout(systemCheck, 100); + +/** + * System check watchdog. Runs every 5 seconds and is used to manage shutdown. + * Can also be used for general housekeeping. + * + * @type {function} systemCheck + * + * Need to ignore eslint complexity warnings here: + */ +// eslint-disable-next-line complexity +function systemCheck() { + /** + * The shutdown tick allows housekeeping before shutdown. + */ + if (utils.systemState.shutdownTick === -1) { + // Check the database state. + if (!mainDB.dbOnline) { + /** + * Database is not online. Figure out why. + */ + if (utils.systemState.firstTime) { + /** + * OK, just starting up for the first time. Connect to database. + */ + utils.systemState.firstTime = 0; + log.system( + 'STARTUP', + 'Initialising database and web servers...', + 'node_server.systemCheck', + '', + 'System', + '127.0.0.1'); + startupDatabase(); + } else { + /** + * Looks like we lost connection. Try to start up again. + */ + startupDatabase(); + } + } + + /** + * Executes every 15 minutes. + */ + if (longTick === 0) { + let status = ''; + + /** + * HTTP services: + */ + if (startupServices.httpOnline) { + status += 'HTTP:80 Up, '; + } else { + status += 'HTTP:80 Down, '; + } + if (mainDB.dbOnline) { + status += 'MDB Up, '; + } else { + status += 'MDB Down, '; + } + status += 'WWW ' + filesServed + ', '; + status += 'JSON ' + hJSON.JSONServed + ', '; + status += 'SMS ' + sms.smsCredits + ', '; + status += 'CRX ' + credorax.primaryFailedComms + ', '; + status += 'WP ' + worldpay.primaryFailedComms + '.'; + + /** + * Output the status information. + */ + log.system( + 'SERVER', + status, + 'node_server.systemCheck', + '', + 'System', + '127.0.0.1'); + + /** + * Reduce the number of failed comms for active acquirers. + * Credorax + */ + if (config.credoraxCurrentGateway === config.credoraxPrimaryGateway) { + if (credorax.primaryFailedComms > 0) { + credorax.primaryFailedComms -= config.credoraxChangeRate; + } + } else { + log.system( + 'WARNING', + 'System using secondary Credorax server.', + 'node_server.systemCheck', + '', + 'System', + '127.0.0.1'); + } + + /** + * Worldpay + */ + if (worldpay.primaryFailedComms > config.worldpayNotificationThreshold) { + log.system( + 'WARNING', + 'Unexpected number of communciations failures with Worldpay primary gateway.', + 'node_server.systemCheck', + '', + 'System', + '127.0.0.1'); + } + if (worldpay.primaryFailedComms > 0) { + worldpay.primaryFailedComms -= config.worldpayChangeRate; + } + + /** + * Reset the tick. + */ + longTick = longTickTime; + } else { + /** + * Do nothing other than decrement. + */ + longTick--; + } + } else if (utils.systemState.shutdownTick > 1) { + /** + * Tick set to higher than 1 - shutdown requested. + */ + utils.systemState.shutdownTick -= 1; + + /** + * Close off the servers. + */ + startupServices.httpOnline = 0; + + /** + * Close off the database. + */ + if (mainDB.dbOnline === 1) { + /** + * Database is online. Log shutdown info. + */ + const logData = {}; + logData.DateTime = new Date(); + logData.ServerID = config.CCServerName + ' (VIP ' + config.CCServerIP + ')'; + logData.Class = 'SHUTDOWN'; + logData.Function = 'node_server.systemCheck'; + logData.Code = ''; + logData.Info = 'Servers offline. Database shutdown in progress...'; + logData.User = 'System'; + logData.Source = '127.0.0.1'; + console.log('[' + logData.DateTime.toISOString() + '] ' + logData.Class + ': ' + logData.Info); + + /** + * Add the info to the log. + */ + mainDB.collectionSystemLog.insert(logData, (err) => { + if (err) { + console.log('[' + String(new Date().toISOString()) + + '] ERROR: Database write error during shutdown. Shutdown complete.' + utils.CarriageReturn); + process.exit(exitCodes.EXIT_CODE_DATABASE_WRITE_ERROR); + } else { + /** + * Close off database. + */ + // eslint-disable-next-line no-shadow + mainDB.mdb.close((err) => { + if (err) { + console.log('[' + String(new Date().toISOString()) + + '] ERROR: Could not correctly close database. Shutdown complete.' + + utils.CarriageReturn); + process.exit(exitCodes.EXIT_CODE_DATABASE_NOT_CLOSED); + } else { + console.log('[' + String(new Date().toISOString()) + + '] SHUTDOWN: Cleanup complete. Exiting process.' + utils.CarriageReturn); + process.exit(exitCodes.EXIT_CODE_SUCCESS); + } + }); + } + }); + } else { + console.log('[' + String(new Date().toISOString()) + + '] ERROR: Database unexpectedly offline. Shutdown complete.' + utils.CarriageReturn); + process.exit(exitCodes.EXIT_CODE_DATABASE_OFFLINE); + } + } else if (utils.systemState.shutdownTick === 1) { + /** + * Give the database time to shut down. + */ + console.log('[' + String(new Date().toISOString()) + + '] SHUTDOWN: Waiting for database shutdown. 5 seconds until forced termination...'); + utils.systemState.shutdownTick -= 1; + } else if (utils.systemState.shutdownTick === 0) { + /** + * Shut down anyway. + */ + console.log('[' + String(new Date().toISOString()) + + '] WARNING: Node server shutdown forced. Database may not have been properly closed.' + utils.CarriageReturn); + process.exit(exitCodes.EXIT_CODE_FORCED_SHUTDOWN); + } + + /** + * Set five second tick. + */ + setTimeout(systemCheck, 5000); +} + +/** + * Simple web server functionality. + * For reasons to do with the way Express leaves ports open, a custom web server is used in this instance. + * + * @param {!object} req - The Mongo collection in which the object exists. + * @param {!object} req.connection - Detail about the connection. + * @param {!object} res - The search parameters for the object(s) to delete in JSON format. + * @param {!function} res.writeHead - Write the response header. + * @param {!function} res.end - Return the header. + * @param {!string} remoteAddress - the remote address the request is made from + * @param {!string} protocolPort - Protocol followed by incoming port e.g. 'HTTP:80'. + * @param {!string} location - Optional callback for async operation. + */ +function serveFile(req, res, remoteAddress, protocolPort, location) { + /** + * Default behaviour is to look for a file to send. + */ + const filename = location; + filesServed++; + + /** + * Check for a null. + */ + // eslint-disable-next-line no-negated-condition + if (filename.indexOf('\0') !== -1) { + log.system( + 'ATTACK', + 'Null byte in path rejected.', + 'node_server.serveFile', + '', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + res.sendStatus(400); + } else { + /** + * Check for someone trying to escape the root directory. + */ + const normalizedFile = path.normalize((rootServerDirectory + filename)); + const normalizedRootDir = path.normalize(rootServerDirectory); + // eslint-disable-next-line no-negated-condition + if (normalizedFile.indexOf(normalizedRootDir) !== 0) { + log.system( + 'ATTACK', + 'Directory traversal rejected.', + 'node_server.serveFile', + '', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + res.sendStatus(403); + } else { + /** + * All good. Serve the file. + */ + async.series([ + function(callback) { + fs.readFile(normalizedFile, (err, data) => { + if (err) { + /** + * Error reading file. Pass error forward. + */ + log.system( + 'WARNING', + ('404 File not found. [' + normalizedFile + ']'), + 'node_server.serveFile', + '', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + return callback(err); + } else { + /** + * Read successfully. + */ + if (verboseWebServer) { + log.system( + 'FILE', + 'File returned [' + normalizedFile + ']', + 'node_server.serveFile', + '', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + } + + /** + * Deal with extensions. + */ + switch (path.extname(filename)) { + case '.png': + res.writeHead(200, {'Content-Type': 'image/png'}); + break; + default: + res.writeHead(200); + } + + /** + * Fill with the rest of the data. Watch for zero length files. + */ + if (data) { + res.end(data); + } else { + res.end(); + } + return callback(); + } + }); + }], + + /** + * Final clause which is executed after everything else or when an error is detected. + */ + (err) => { + if (err) { + res.sendStatus(404); + } + } + ); + } + } +} + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * + * @param {!object} req - The Mongo collection in which the object exists. + * @param {!object} req.connection - Detail about the connection. + * @param {!object} req.url - Detail about the requested url. + * @param {!string[]} req.headers - The headers in the request packet. + * @param {!object} res - The search parameters for the object(s) to delete in JSON format. + * @param {!function} res.writeHead - Write the response header. + * @param {!function} res.end - Return the header. + * @param {!function} res.setHeader - Sets the response header. + * @param {!string} remoteAddress - the remote address the request is made from + * @param {!string} protocolPort - Protocol followed by incoming port e.g. 'HTTP:80' + */ +function processRequest(req, res, remoteAddress, protocolPort) { + try { + /** + * Parse the URL in case anything needs to be removed. + */ + const currentUrl = url.parse(req.url); + + /** + * Switch on path name. + */ + switch (currentUrl.pathname.toUpperCase()) { + case '/SERVER_POST': // Use this for JSON requests. All requests should use one of the two. + hJSON.handleJSONRequest(req, res, remoteAddress, protocolPort, querystring.parse(currentUrl.query), hJSON.REST); + break; + default: + /* + * Default action is to consider this a file request. + */ + serveFile(req, res, remoteAddress, protocolPort, currentUrl.pathname); + } + } catch (error) { + /** + * Unhandled exception. + */ + log.system( + 'CRITICAL', + ('Unhandled error condition. ' + error.name + ' (' + error.message + ')'), + 'node_server.processRequest', + '', + 'UU', + (remoteAddress + ' (' + protocolPort + ')')); + res.status(500).json({ + code: -1, + info: 'Unexpected server error' + }); + } +} + +/** + * HTTP server (80). + */ +const appHttp = express(); +const serverHTTP = http.createServer(appHttp); + +/** + * Set up an error handler + */ +serverHTTP.on('error', (err) => { + log.system( + 'CRITICAL', + String(err), + 'node_server.serverHTTP.on', + '', + 'UU', + '127.0.0.1'); +}); + +/** + * Next start up the listener. + */ +serverHTTP.listen(serverHTTPport); +serverHTTP.timeout = utils.webTimeout; + +/* + * Security related settings + * See https://www.npmjs.com/package/helmet for more on why we need these + */ +const ninetyDaysInS = 90 * 24 * 60 * 60; +appHttp.use(helmet.frameguard({action: 'deny'})); // Protect against click-jacking +appHttp.use(helmet.xssFilter()); // Browser internal xss protection +if (config.useHTTPS) { + appHttp.use(helmet.hsts({ // Request *subsequent* browser visits use https + maxAge: ninetyDaysInS // for the next 90 days (not enforceable). + })); +} +appHttp.use(helmet.hidePoweredBy()); // Hide the "x-powered-by: Express" header +appHttp.use(helmet.ieNoOpen()); // IE specific issue +appHttp.use(helmet.noSniff()); // Prevent dynamic mime type "sniffing" +appHttp.set('trust proxy', config.CCServerIP); // Sets the proxy up correctly which is required for containers. + +/** + * Load the swagger API router to handle `/api/*` routes + */ +const initConsoleApi = require('./swagger_api/api_server.js'); + +/** + * Load the integration API router to handle `/int/*` routes + */ +const initIntegrationApi = require('./integration_api/int_api_server.js'); + +const integrationApiRouter = initIntegrationApi.init(); +appHttp.use('/int', integrationApiRouter); + +/** + * Load the dev API router to handle `/dev/*` routes + */ +const initDevApi = require('./dev_api/dev_server.js'); + +const devApiRouter = initDevApi.init(); +appHttp.use('/dev', devApiRouter); + +/* + * Load the router to serve the web console from /portal/ + */ +const portalRouterFactory = require('./portal-router.js'); + +const portalRouter = portalRouterFactory(rootPortalDirectory); +appHttp.use('/portal', portalRouter); + +/* + * Redirect any calls to '/' to the portal. + */ +appHttp.get('/', (req, res) => { + res.redirect('/portal/login'); +}); + +/* + * Load the router to serve the metrics from /metrics/ + */ +const promRouterFactory = require('./prometheus-router.js'); + +const promRouter = promRouterFactory(); +appHttp.use('/metrics', promRouter); + +/** + * Enable rate limits for the other paths + */ +rateLimit.enableLimits(appHttp); + +/* + * Load the swagger definitions of the API. This is asynchronous, but must be + * loaded before setting up the processRequest handler. + */ +(async () => { + const consoleApiRouter = await initConsoleApi(mainDB.dbAddress, + mongoConnectOptions, + 'WebConsoleSessions'); + appHttp.use('/api', consoleApiRouter); + + /** + * Route everything else to the processRequest handlers. + */ + appHttp.all('*', (req, res) => { + if (startupServices.httpOnline && utils.isLBHTTPS(req, res)) { + /** + * Different firewall headers depending on the source of the data. + * To get in to this code the services have been called from a trusted proxy. + * Technically the protocolPort should always be 'HTTPS:443' if the code has + * reached here, but it is taken from the headers if available for verification. + */ + let remoteAddress; + let protocolPort; + switch (global.CURRENT_DEPLOYMENT_ENV) { + case 'Azure': + remoteAddress = req.ip.split(':')[0]; + protocolPort = req.protocol + ':' + req.headers['x-forwarded-port']; + break; + case 'Bluemix': + remoteAddress = req.headers.$wsra; + protocolPort = req.headers.$wssc + ':' + req.headers.$wssp; + break; + case 'Flexiion': + default: + remoteAddress = req.ip; + protocolPort = req.protocol + ':443'; + } + + /** + * Process the request. + */ + processRequest(req, res, remoteAddress, protocolPort); + } + }); +})(); + +/** + * Indicate startup is complete. + */ +if (startupServices.httpOnline) { + log.system( + 'STARTUP', + ('HTTP server listening on port ' + serverHTTPport + '.'), + 'node_server.serverHTTP', + '', + 'System', + '127.0.0.1'); +} else { + log.system( + 'WARNING', + ('HTTP server attached to port ' + serverHTTPport + ' but service is disabled.'), + 'node_server.serverHTTP', + '', + 'System', + '127.0.0.1'); +} diff --git a/node_server/package-lock.json b/node_server/package-lock.json new file mode 100644 index 0000000..3666309 --- /dev/null +++ b/node_server/package-lock.json @@ -0,0 +1,10078 @@ +{ + "name": "comcarde-node-server", + "version": "7.6.4", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/node": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.5.8.tgz", + "integrity": "sha512-8KmlRxwbKZfjUHFIt3q8TF5S2B+/E5BaAoo/3mgc5h6FJzqxXkCK/VMetO+IRDtwtU6HUvovHMBn+XRj7SV9Qg==" + }, + "@types/winston": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/winston/-/winston-2.3.8.tgz", + "integrity": "sha512-QqR0j08RCS1AQYPMRPHikEpcmK+2aEEbcSzWLwOqyJ4FhLmHUx/WjRrnn7tTQg/y4IKnMhzskh/o7qvGIZZ7iA==", + "requires": { + "@types/node": "8.5.8" + } + }, + "JSV": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz", + "integrity": "sha1-0Hf2glVx+CEy+d/67Vh7QCn+/1c=", + "dev": true + }, + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", + "dev": true + }, + "accepts": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.4.tgz", + "integrity": "sha1-hiRnWMfdbSGmR0/whKR0DsBesh8=", + "requires": { + "mime-types": "2.1.17", + "negotiator": "0.6.1" + } + }, + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=" + }, + "acorn-globals": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-3.1.0.tgz", + "integrity": "sha1-/YJw9x+7SZawBPqIDuXUZXOnMb8=", + "requires": { + "acorn": "4.0.13" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=" + } + } + }, + "addressparser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-0.3.2.tgz", + "integrity": "sha1-WYc/Nej89sc2HBAjkmHXbhU0i7I=" + }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "requires": { + "co": "4.6.0", + "fast-deep-equal": "1.0.0", + "fast-json-stable-stringify": "2.0.0", + "json-schema-traverse": "0.3.1" + } + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "requires": { + "kind-of": "3.2.2", + "longest": "1.0.1", + "repeat-string": "1.6.1" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" + }, + "ansi-bgblack": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bgblack/-/ansi-bgblack-0.1.1.tgz", + "integrity": "sha1-poulAHiHcBtqr74/oNrf36juPKI=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bgblue": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bgblue/-/ansi-bgblue-0.1.1.tgz", + "integrity": "sha1-Z73ATtybm1J4lp2hlt6j11yMNhM=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bgcyan": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bgcyan/-/ansi-bgcyan-0.1.1.tgz", + "integrity": "sha1-WEiUJWAL3p9VBwaN2Wnr/bUP52g=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bggreen": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bggreen/-/ansi-bggreen-0.1.1.tgz", + "integrity": "sha1-TjGRJIUplD9DIelr8THRwTgWr0k=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bgmagenta": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bgmagenta/-/ansi-bgmagenta-0.1.1.tgz", + "integrity": "sha1-myhDLAduqpmUGGcqPvvhk5HCx6E=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bgred": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bgred/-/ansi-bgred-0.1.1.tgz", + "integrity": "sha1-p2+Sg4OCukMpCmwXeEJPmE1vEEE=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bgwhite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bgwhite/-/ansi-bgwhite-0.1.1.tgz", + "integrity": "sha1-ZQRlE3elim7OzQMxmU5IAljhG6g=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bgyellow": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bgyellow/-/ansi-bgyellow-0.1.1.tgz", + "integrity": "sha1-w/4usIzUdmSAKeaHTRWgs49h1E8=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-black": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-black/-/ansi-black-0.1.1.tgz", + "integrity": "sha1-9hheiJNgslRaHsUMC/Bj/EMDJFM=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-blue": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-blue/-/ansi-blue-0.1.1.tgz", + "integrity": "sha1-FbgEmQ6S/JyoxUds6PaZd3wh7b8=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-bold": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-bold/-/ansi-bold-0.1.1.tgz", + "integrity": "sha1-PmOVCvWswq4uZw5vZ96xFdGl9QU=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-colors": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-0.2.0.tgz", + "integrity": "sha1-csMd4qDZoszQysMMyYI+6y9kNLU=", + "dev": true, + "requires": { + "ansi-bgblack": "0.1.1", + "ansi-bgblue": "0.1.1", + "ansi-bgcyan": "0.1.1", + "ansi-bggreen": "0.1.1", + "ansi-bgmagenta": "0.1.1", + "ansi-bgred": "0.1.1", + "ansi-bgwhite": "0.1.1", + "ansi-bgyellow": "0.1.1", + "ansi-black": "0.1.1", + "ansi-blue": "0.1.1", + "ansi-bold": "0.1.1", + "ansi-cyan": "0.1.1", + "ansi-dim": "0.1.1", + "ansi-gray": "0.1.1", + "ansi-green": "0.1.1", + "ansi-grey": "0.1.1", + "ansi-hidden": "0.1.1", + "ansi-inverse": "0.1.1", + "ansi-italic": "0.1.1", + "ansi-magenta": "0.1.1", + "ansi-red": "0.1.1", + "ansi-reset": "0.1.1", + "ansi-strikethrough": "0.1.1", + "ansi-underline": "0.1.1", + "ansi-white": "0.1.1", + "ansi-yellow": "0.1.1", + "lazy-cache": "2.0.2" + }, + "dependencies": { + "lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=", + "dev": true, + "requires": { + "set-getter": "0.1.0" + } + } + } + }, + "ansi-cyan": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-cyan/-/ansi-cyan-0.1.1.tgz", + "integrity": "sha1-U4rlKK+JgvKK4w2G8vF0VtJgmHM=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-dim": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-dim/-/ansi-dim-0.1.1.tgz", + "integrity": "sha1-QN5MYDqoCG2Oeoa4/5mNXDbu/Ww=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha1-KWLPVOyXksSFEKPetSRDaGHvclE=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-green": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-green/-/ansi-green-0.1.1.tgz", + "integrity": "sha1-il2al55FjVfEDjNYCzc5C44Q0Pc=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-grey": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-grey/-/ansi-grey-0.1.1.tgz", + "integrity": "sha1-WdmLasK6GfilF5jphT+6eDOaM8E=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-hidden": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-hidden/-/ansi-hidden-0.1.1.tgz", + "integrity": "sha1-7WpMSY0rt8uyidvyqNHcyFZ/rg8=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-inverse": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-inverse/-/ansi-inverse-0.1.1.tgz", + "integrity": "sha1-tq9Fgm/oJr+1KKbHmIV5Q1XM0mk=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-italic": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-italic/-/ansi-italic-0.1.1.tgz", + "integrity": "sha1-EEdDRj9iXBQqA2c5z4XtpoiYbyM=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-magenta": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-magenta/-/ansi-magenta-0.1.1.tgz", + "integrity": "sha1-BjtboW+z8j4c/aKwfAqJ3hHkMK4=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha1-jGOPnRCAgAo1PJwoyKgcpHBdlGw=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-reset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-reset/-/ansi-reset-0.1.1.tgz", + "integrity": "sha1-5+cSksPH3c1NYu9KbHwFmAkRw7c=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-strikethrough": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-strikethrough/-/ansi-strikethrough-0.1.1.tgz", + "integrity": "sha1-2Eh3FAss/wfRyT685pkE9oiF5Wg=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "ansi-underline": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-underline/-/ansi-underline-0.1.1.tgz", + "integrity": "sha1-38kg9Ml7WXfqFi34/7mIMIqqcaQ=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-white": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-white/-/ansi-white-0.1.1.tgz", + "integrity": "sha1-nHe3wZPF7pkuYBHTbsTJIbRXiUQ=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=", + "dev": true + }, + "ansi-yellow": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-yellow/-/ansi-yellow-0.1.1.tgz", + "integrity": "sha1-y5NW8vRscy8OMZnmEClVp32oPB0=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "anymatch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", + "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==", + "requires": { + "micromatch": "2.3.11", + "normalize-path": "2.1.1" + } + }, + "append-field": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-0.1.0.tgz", + "integrity": "sha1-bdxY+gg8e8VF08WZWygwzCNm1Eo=" + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, + "argparse": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz", + "integrity": "sha1-c9g7wmP4bpf4zE9rrhsOkKfSLIY=", + "requires": { + "sprintf-js": "1.0.3" + } + }, + "arr-diff": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", + "requires": { + "arr-flatten": "1.1.0" + } + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==" + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=", + "dev": true + }, + "array-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", + "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "array-parallel": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", + "integrity": "sha1-j3hTCJJu1apHjEfmTRszS2wMlH0=" + }, + "array-series": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", + "integrity": "sha1-3103v8XC7wdV4qpPkv6ufUtaly8=" + }, + "array-slice": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", + "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "dev": true + }, + "array-sort": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/array-sort/-/array-sort-0.1.4.tgz", + "integrity": "sha512-BNcM+RXxndPxiZ2rd76k6nyQLRZr2/B/sdi8pQ+Joafr5AH279L40dfokSUTp8O+AaqYjXWhblBWa2st2nc4fQ==", + "dev": true, + "requires": { + "default-compare": "1.0.0", + "get-value": "2.0.6", + "kind-of": "5.1.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=" + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "async-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", + "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "atob": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.0.3.tgz", + "integrity": "sha1-GcenYEc3dEaPILLS0DNyrX1Mv10=", + "dev": true + }, + "autolinker": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-0.15.3.tgz", + "integrity": "sha1-NCQX2PLzRhsUzwkIjV7fh5HcmDI=", + "dev": true + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", + "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "2.5.3", + "regenerator-runtime": "0.11.1" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "1.0.1", + "class-utils": "0.3.5", + "component-emitter": "1.2.1", + "define-property": "1.0.0", + "isobject": "3.0.1", + "mixin-deep": "1.3.0", + "pascalcase": "0.1.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "base64url": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-2.0.0.tgz", + "integrity": "sha1-6sFuA+oUOO/5Qj1puqNiYu0fcLs=" + }, + "basic-auth": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz", + "integrity": "sha1-AV2z81PgLlY3d1X5YnQuiYHnu7o=", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "optional": true, + "requires": { + "tweetnacl": "0.14.5" + } + }, + "beeper": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.1.tgz", + "integrity": "sha1-5tXqjF2tABMEpwsiY4RH9pyy+Ak=", + "dev": true + }, + "binary-extensions": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.11.0.tgz", + "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU=" + }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, + "bit-buffer": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/bit-buffer/-/bit-buffer-0.0.3.tgz", + "integrity": "sha1-QWwPxy5dL7tPDsw9ufAL+tMgxDY=" + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" + }, + "body-parser": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", + "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "1.0.4", + "debug": "2.6.9", + "depd": "1.1.1", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "on-finished": "2.3.0", + "qs": "6.5.1", + "raw-body": "2.3.2", + "type-is": "1.6.15" + } + }, + "boom": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", + "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", + "requires": { + "hoek": "4.2.0" + } + }, + "brace-expansion": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", + "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", + "requires": { + "expand-range": "1.8.2", + "preserve": "0.2.0", + "repeat-element": "1.1.2" + } + }, + "broadway": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/broadway/-/broadway-0.3.6.tgz", + "integrity": "sha1-fb7waLlUt5B5Jf1USWO1eKkCuno=", + "requires": { + "cliff": "0.1.9", + "eventemitter2": "0.4.14", + "nconf": "0.6.9", + "utile": "0.2.1", + "winston": "0.8.0" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, + "cliff": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/cliff/-/cliff-0.1.9.tgz", + "integrity": "sha1-ohHgnGo947oa8n0EnTASUNGIErw=", + "requires": { + "colors": "0.6.2", + "eyes": "0.1.8", + "winston": "0.8.0" + } + }, + "winston": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-0.8.0.tgz", + "integrity": "sha1-YdCDD6aZcGISIGsKK1ymmpMENmg=", + "requires": { + "async": "0.2.10", + "colors": "0.6.2", + "cycle": "1.0.3", + "eyes": "0.1.8", + "pkginfo": "0.3.1", + "stack-trace": "0.0.10" + } + } + } + }, + "browser-stdout": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "dev": true + }, + "bson": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.0.4.tgz", + "integrity": "sha1-k8ENOeqltYQVy8QFLz5T5WKwtyw=" + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, + "buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=" + }, + "buildmail": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buildmail/-/buildmail-2.0.0.tgz", + "integrity": "sha1-8LewpZ6aShtQZrv6BR0kjzgy7s4=", + "requires": { + "addressparser": "0.3.2", + "libbase64": "0.1.0", + "libmime": "1.2.0", + "libqp": "1.1.0", + "needle": "0.10.0" + }, + "dependencies": { + "needle": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-0.10.0.tgz", + "integrity": "sha1-FqJNY/KmEVLrdMzh0Sr4XFB1d9Q=", + "requires": { + "debug": "2.6.9", + "iconv-lite": "0.4.19" + } + } + } + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, + "bump-regex": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/bump-regex/-/bump-regex-2.9.0.tgz", + "integrity": "sha512-o4WC1mKw/kM0zScuOxZKi243lc+/h09b41u2A7HlWbxHsEDsTTZtqDZYkQj65l24J8+9Saahn5ep+EyeqpQoCg==", + "dev": true, + "requires": { + "semver": "5.4.1", + "xtend": "4.0.1" + }, + "dependencies": { + "semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true + } + } + }, + "busboy": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", + "integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=", + "requires": { + "dicer": "0.2.5", + "readable-stream": "1.1.14" + }, + "dependencies": { + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + } + } + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "1.0.0", + "component-emitter": "1.2.1", + "get-value": "2.0.6", + "has-value": "1.0.0", + "isobject": "3.0.1", + "set-value": "2.0.0", + "to-object-path": "0.3.0", + "union-value": "1.0.0", + "unset-value": "1.0.0" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "call-me-maybe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", + "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", + "dev": true + }, + "caller": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/caller/-/caller-0.0.1.tgz", + "integrity": "sha1-83odbqEOgp2UchrimpC7T7Uqt2c=", + "requires": { + "tape": "2.3.3" + } + }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=" + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "requires": { + "camelcase": "2.1.1", + "map-obj": "1.0.1" + }, + "dependencies": { + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true + } + } + }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" + }, + "canduit": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/canduit/-/canduit-1.3.1.tgz", + "integrity": "sha1-EMWlb01uCaF1DYI0QYudb+V6G8w=", + "dev": true, + "requires": { + "request": "2.83.0" + } + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "requires": { + "align-text": "0.1.4", + "lazy-cache": "1.0.4" + } + }, + "chai": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", + "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", + "dev": true, + "requires": { + "assertion-error": "1.1.0", + "check-error": "1.0.2", + "deep-eql": "3.0.1", + "get-func-name": "2.0.0", + "pathval": "1.1.0", + "type-detect": "4.0.5" + } + }, + "chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "requires": { + "check-error": "1.0.2" + } + }, + "chai-deep-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chai-deep-match/-/chai-deep-match-1.0.2.tgz", + "integrity": "sha1-sBX7eu9CF1ky4fjnKSlVhanRgTc=", + "dev": true, + "requires": { + "deep-keys": "0.2.0", + "lodash": "4.17.4", + "lodash-pickdeep": "1.0.2" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "2.2.1", + "escape-string-regexp": "1.0.5", + "has-ansi": "2.0.0", + "strip-ansi": "3.0.1", + "supports-color": "2.0.0" + } + }, + "character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=", + "requires": { + "is-regex": "1.0.4" + } + }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", + "dev": true + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "chokidar": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", + "integrity": "sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=", + "requires": { + "anymatch": "1.3.2", + "async-each": "1.0.1", + "fsevents": "1.1.3", + "glob-parent": "2.0.0", + "inherits": "2.0.3", + "is-binary-path": "1.0.1", + "is-glob": "2.0.1", + "path-is-absolute": "1.0.1", + "readdirp": "2.1.0" + } + }, + "class-utils": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.5.tgz", + "integrity": "sha1-F+eTEDdQ+WJ7IXbqNM/RtWWQPIA=", + "dev": true, + "requires": { + "arr-union": "3.1.0", + "define-property": "0.2.5", + "isobject": "3.0.1", + "lazy-cache": "2.0.2", + "static-extend": "0.1.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + }, + "lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=", + "dev": true, + "requires": { + "set-getter": "0.1.0" + } + } + } + }, + "clean-css": { + "version": "3.4.28", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-3.4.28.tgz", + "integrity": "sha1-vxlF6C/ICPVWlebd6uwBQA79A/8=", + "requires": { + "commander": "2.8.1", + "source-map": "0.4.4" + }, + "dependencies": { + "commander": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", + "integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=", + "requires": { + "graceful-readlink": "1.0.1" + } + } + } + }, + "cli-table": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.1.tgz", + "integrity": "sha1-9TsFJmqLGguTSz0IIebi3FkUriM=", + "dev": true, + "requires": { + "colors": "1.0.3" + }, + "dependencies": { + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", + "dev": true + } + } + }, + "cliff": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/cliff/-/cliff-0.1.10.tgz", + "integrity": "sha1-U74z6p9ZvshWCe4wCsQgdgPlIBM=", + "requires": { + "colors": "1.0.3", + "eyes": "0.1.8", + "winston": "0.8.3" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + }, + "winston": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-0.8.3.tgz", + "integrity": "sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA=", + "requires": { + "async": "0.2.10", + "colors": "0.6.2", + "cycle": "1.0.3", + "eyes": "0.1.8", + "isstream": "0.1.2", + "pkginfo": "0.3.1", + "stack-trace": "0.0.10" + }, + "dependencies": { + "colors": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", + "integrity": "sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w=" + } + } + } + } + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "requires": { + "center-align": "0.1.3", + "right-align": "0.1.3", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=" + } + } + }, + "clone": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.3.tgz", + "integrity": "sha1-KY1+IjFmD0DAA8LtMUDezz9TCF8=" + }, + "clone-stats": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz", + "integrity": "sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=", + "dev": true + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "1.0.0", + "object-visit": "1.0.1" + } + }, + "color": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/color/-/color-0.8.0.tgz", + "integrity": "sha1-iQwHw/1OZJU3Y4kRz2keVFi2/KU=", + "requires": { + "color-convert": "0.5.3", + "color-string": "0.3.0" + } + }, + "color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha1-vbbGnOZg+t/+CwAHzER+G59ygr0=" + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "color-string": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz", + "integrity": "sha1-J9RvtnAlxcL6JZk7+/V55HhBuZE=", + "requires": { + "color-name": "1.1.3" + } + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "colornames": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/colornames/-/colornames-0.0.2.tgz", + "integrity": "sha1-2BH9bIT1kClJmorEQ2ICk1uSvjE=" + }, + "colors": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", + "integrity": "sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w=" + }, + "colorspace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.0.1.tgz", + "integrity": "sha1-yZx5btMRKLmHalLh7l7gOkpxl0k=", + "requires": { + "color": "0.8.0", + "text-hex": "0.0.0" + } + }, + "combined-stream": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", + "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", + "requires": { + "delayed-stream": "1.0.0" + } + }, + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==" + }, + "comment-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-0.3.2.tgz", + "integrity": "sha1-PAPwd2uGo239mgosl8YwfzMggv4=", + "dev": true, + "requires": { + "readable-stream": "2.3.3" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + } + } + }, + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "compressible": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.12.tgz", + "integrity": "sha1-xZpcmdt2dn6YdlAOJx72OzSTvWY=", + "requires": { + "mime-db": "1.30.0" + } + }, + "compression": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.1.tgz", + "integrity": "sha1-7/JgPvwuIs+G810uuTWJ+YdTc9s=", + "requires": { + "accepts": "1.3.4", + "bytes": "3.0.0", + "compressible": "2.0.12", + "debug": "2.6.9", + "on-headers": "1.0.1", + "safe-buffer": "5.1.1", + "vary": "1.1.2" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.0.tgz", + "integrity": "sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc=", + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.3", + "typedarray": "0.0.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "5.1.1" + } + } + } + }, + "connect-mongo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-1.3.2.tgz", + "integrity": "sha1-fL9Y3/8mdg5eAOAX0KhbS8kLnTc=", + "requires": { + "bluebird": "3.5.1", + "mongodb": "2.2.34" + } + }, + "constantinople": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-3.1.0.tgz", + "integrity": "sha1-dWnKqKo/jVk11i4fqW+fcCzYHHk=", + "requires": { + "acorn": "3.3.0", + "is-expression": "2.1.0" + } + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-security-policy-builder": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-1.1.0.tgz", + "integrity": "sha1-2R8bB2I2wRmFDH3umSS/VeBXcrM=", + "requires": { + "dashify": "0.2.2" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cookiejar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.1.tgz", + "integrity": "sha1-Qa1XsbVVlR7BcUEqgZQrHoIA00o=" + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "core-js": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.3.tgz", + "integrity": "sha1-isw4NFgk8W2DZbfJtCWRaOjtYD4=", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "crc": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.5.0.tgz", + "integrity": "sha1-mLi6fUiWZbo5efWbITgTdBAaGWQ=" + }, + "create-frame": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/create-frame/-/create-frame-1.0.0.tgz", + "integrity": "sha1-i5XyaR4ySbYIBEPjPQutn49pdao=", + "dev": true, + "requires": { + "define-property": "0.2.5", + "extend-shallow": "2.0.1", + "isobject": "3.0.1", + "lazy-cache": "2.0.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + }, + "lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=", + "dev": true, + "requires": { + "set-getter": "0.1.0" + } + } + } + }, + "cross-spawn": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", + "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=", + "requires": { + "lru-cache": "4.1.1", + "which": "1.3.0" + } + }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", + "dev": true + }, + "cryptiles": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", + "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", + "requires": { + "boom": "5.2.0" + }, + "dependencies": { + "boom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", + "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", + "requires": { + "hoek": "4.2.0" + } + } + } + }, + "cst": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/cst/-/cst-0.4.10.tgz", + "integrity": "sha512-U5ETe1IOjq2h56ZcBE3oe9rT7XryCH6IKgPMv0L7sSk6w29yR3p5egCK0T3BDNHHV95OoUBgXsqiVG+3a900Ag==", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babylon": "6.18.0", + "source-map-support": "0.4.18" + } + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "1.0.2" + } + }, + "cycle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", + "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=" + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "1.0.0" + } + }, + "dasherize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dasherize/-/dasherize-2.0.0.tgz", + "integrity": "sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg=" + }, + "dashify": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dashify/-/dashify-0.2.2.tgz", + "integrity": "sha1-agdBWgHJH69KMuONnfunH2HLIP4=" + }, + "data-uri-to-buffer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.0.tgz", + "integrity": "sha512-YbKCNLPPP4inc0E5If4OaalBc7gpaM2MRv77Pv2VThVComLKfbGYtJcdDCViDyp1Wd4SebhHLz94vp91zbK6bw==", + "requires": { + "@types/node": "8.5.8" + } + }, + "date.js": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/date.js/-/date.js-0.3.2.tgz", + "integrity": "sha512-wCedqqkYrduV8nH+OftEdGZzsJGgZ6tj1c1YNhcsrdysE0b0YzHzAeo1P83FICx1ULsuDsTFDHxyFBch/Ec2kg==", + "dev": true, + "requires": { + "debug": "3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "dateformat": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", + "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "4.0.5" + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "deep-keys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-keys/-/deep-keys-0.2.0.tgz", + "integrity": "sha1-QgdPDLaosShmnwM9PhbA1qgGx2M=", + "dev": true + }, + "default-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", + "integrity": "sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ==", + "dev": true, + "requires": { + "kind-of": "5.1.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "requires": { + "clone": "1.0.3" + } + }, + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "1.0.2" + } + }, + "defined": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-0.0.0.tgz", + "integrity": "sha1-817qfXBekzuvE7LwOz+D2SFAOz4=" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" + }, + "deprecated": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz", + "integrity": "sha1-+cmvVGSvoeepcUWKi97yqpTVuxk=", + "dev": true + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", + "dev": true + }, + "diagnostics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.0.tgz", + "integrity": "sha1-4QkJALSVI+hSe+IPCBJ1IF8q42o=", + "requires": { + "colorspace": "1.0.1", + "enabled": "1.0.2", + "kuler": "0.0.0" + } + }, + "dicer": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", + "integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=", + "requires": { + "readable-stream": "1.1.14", + "streamsearch": "0.1.2" + }, + "dependencies": { + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + } + } + }, + "diff": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "dev": true + }, + "director": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/director/-/director-1.2.7.tgz", + "integrity": "sha1-v9N0EHX9f7GlsuE2WMX0vsd3NvM=" + }, + "dns-prefetch-control": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz", + "integrity": "sha1-YN20V3dOF48flBXwyrsOhbCzALI=" + }, + "doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk=" + }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "dev": true, + "requires": { + "domelementtype": "1.1.3", + "entities": "1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", + "dev": true + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", + "dev": true + } + } + }, + "domelementtype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", + "dev": true + }, + "domhandler": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", + "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", + "dev": true, + "requires": { + "domelementtype": "1.3.0" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dev": true, + "requires": { + "dom-serializer": "0.1.0", + "domelementtype": "1.3.0" + } + }, + "dont-sniff-mimetype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.0.0.tgz", + "integrity": "sha1-WTKJDcn04vGeXrAqIAJuXl78j1g=" + }, + "duplexer2": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", + "integrity": "sha1-xhTc9n4vsUmVqRcR5aYX6KYKMds=", + "dev": true, + "requires": { + "readable-stream": "1.1.14" + }, + "dependencies": { + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + } + } + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz", + "integrity": "sha1-S8kmJ07Dtau1AW5+HWCSGsJisqE=", + "requires": { + "base64url": "2.0.0", + "safe-buffer": "5.1.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "ejs": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.5.7.tgz", + "integrity": "sha1-zIcsFoiArjxxiXYv1f/ACJbJUYo=" + }, + "enabled": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", + "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", + "requires": { + "env-variable": "0.0.3" + } + }, + "encodeurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz", + "integrity": "sha1-eePVhlU0aQn+bw9Fpd5oEDspTSA=" + }, + "end-of-stream": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz", + "integrity": "sha1-jhdyBsPICDfYVjLouTWd/osvbq8=", + "dev": true, + "requires": { + "once": "1.3.3" + }, + "dependencies": { + "once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha1-suJhVXzkwxTsgwTz+oJmPkKXyiA=", + "dev": true, + "requires": { + "wrappy": "1.0.2" + } + } + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=", + "dev": true + }, + "entities": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", + "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", + "dev": true + }, + "env-variable": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.3.tgz", + "integrity": "sha1-uGwWQb5WECZ9UG8YBx6nbXBwl8s=" + }, + "error-ex": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", + "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=", + "dev": true, + "requires": { + "is-arrayish": "0.2.1" + } + }, + "error-symbol": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/error-symbol/-/error-symbol-0.1.0.tgz", + "integrity": "sha1-Ck2uN9YA0VopukU9jvkg8YRDM/Y=", + "dev": true + }, + "es6-promise": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz", + "integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q=" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "dev": true, + "requires": { + "esprima": "2.7.3", + "estraverse": "1.9.3", + "esutils": "2.0.2", + "optionator": "0.8.2", + "source-map": "0.2.0" + }, + "dependencies": { + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + }, + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=" + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-stream": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-0.5.3.tgz", + "integrity": "sha1-t3uTCfcQet3+q2PwwOr9jbC9jBw=", + "requires": { + "optimist": "0.2.8" + }, + "dependencies": { + "optimist": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.2.8.tgz", + "integrity": "sha1-6YGrfiaLRXlIWTtVZ0wJmoFcrDE=", + "requires": { + "wordwrap": "0.0.3" + } + } + } + }, + "eventemitter2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", + "integrity": "sha1-j2G3XN4BKy6esoTUVFWDtWQ7Yas=" + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "dev": true, + "requires": { + "cross-spawn": "5.1.0", + "get-stream": "3.0.0", + "is-stream": "1.1.0", + "npm-run-path": "2.0.2", + "p-finally": "1.0.0", + "signal-exit": "3.0.2", + "strip-eof": "1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "4.1.1", + "shebang-command": "1.2.0", + "which": "1.3.0" + } + } + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "expand-brackets": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", + "requires": { + "is-posix-bracket": "0.1.1" + } + }, + "expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=", + "requires": { + "fill-range": "2.2.3" + } + }, + "expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=", + "dev": true, + "requires": { + "homedir-polyfill": "1.0.1" + } + }, + "expect-ct": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/expect-ct/-/expect-ct-0.1.0.tgz", + "integrity": "sha1-UnNWeN4YUwiQ2Ne5XwrGNkCVgJQ=" + }, + "express": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.2.tgz", + "integrity": "sha1-41xt/i1kt9ygpc1PIXgb4ymeB2w=", + "requires": { + "accepts": "1.3.4", + "array-flatten": "1.1.1", + "body-parser": "1.18.2", + "content-disposition": "0.5.2", + "content-type": "1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "1.1.1", + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "etag": "1.8.1", + "finalhandler": "1.1.0", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "1.1.2", + "on-finished": "2.3.0", + "parseurl": "1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "2.0.2", + "qs": "6.5.1", + "range-parser": "1.2.0", + "safe-buffer": "5.1.1", + "send": "0.16.1", + "serve-static": "1.13.1", + "setprototypeof": "1.1.0", + "statuses": "1.3.1", + "type-is": "1.6.15", + "utils-merge": "1.0.1", + "vary": "1.1.2" + }, + "dependencies": { + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "statuses": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", + "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=" + } + } + }, + "express-rate-limit": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-2.11.0.tgz", + "integrity": "sha512-KMZayDxj3Wr7zYuwTuDZj5hMW0nhnyJVBVCwMEVKwMdW6CkYh4vnfnUbRJYhKC0v6UuIbPerwKY0dqWmEzFjKA==", + "requires": { + "defaults": "1.0.3" + } + }, + "express-session": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.15.6.tgz", + "integrity": "sha512-r0nrHTCYtAMrFwZ0kBzZEXa1vtPVrw0dKvGSrKP4dahwBQ1BJpF2/y1Pp4sCD/0kvxV4zZeclyvfmw0B4RMJQA==", + "requires": { + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "crc": "3.4.4", + "debug": "2.6.9", + "depd": "1.1.1", + "on-headers": "1.0.1", + "parseurl": "1.3.2", + "uid-safe": "2.1.5", + "utils-merge": "1.0.1" + }, + "dependencies": { + "crc": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.4.4.tgz", + "integrity": "sha1-naHpgOO9RPxck79as9ozeNheRms=" + } + } + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "0.1.1" + } + }, + "extglob": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", + "requires": { + "is-extglob": "1.0.0" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=" + }, + "falsey": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/falsey/-/falsey-0.3.2.tgz", + "integrity": "sha512-lxEuefF5MBIVDmE6XeqCdM4BWk1+vYmGZtkbKZ/VFcg6uBBw6fXNEbWmxCjDdQlFc9hy450nkiWwM3VAW6G1qg==", + "dev": true, + "requires": { + "kind-of": "5.1.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "fancy-log": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.2.tgz", + "integrity": "sha1-9BEl49hPLn2JpD0G2VjI94vha+E=", + "dev": true, + "requires": { + "ansi-gray": "0.1.1", + "color-support": "1.1.3", + "time-stamp": "1.1.0" + } + }, + "fast-deep-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", + "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fecha": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.2.tgz", + "integrity": "sha1-Ng8DXdbt2VS8lYH5XypKfyo1BcE=" + }, + "filename-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=" + }, + "fill-range": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz", + "integrity": "sha1-ULd9/X5Gm8dJJHCWNpn+eoSFpyM=", + "requires": { + "is-number": "2.1.0", + "isobject": "2.1.0", + "randomatic": "1.1.7", + "repeat-element": "1.1.2", + "repeat-string": "1.6.1" + } + }, + "finalhandler": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.0.tgz", + "integrity": "sha1-zgtoVbRYU+eRsvzGgARtiCU91/U=", + "requires": { + "debug": "2.6.9", + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "on-finished": "2.3.0", + "parseurl": "1.3.2", + "statuses": "1.3.1", + "unpipe": "1.0.0" + }, + "dependencies": { + "statuses": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", + "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=" + } + } + }, + "find-index": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz", + "integrity": "sha1-Z101iyyjiS15Whq0cjL4tuLg3eQ=", + "dev": true + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "2.1.0", + "pinkie-promise": "2.0.1" + } + }, + "findup-sync": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", + "integrity": "sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=", + "dev": true, + "requires": { + "detect-file": "1.0.0", + "is-glob": "3.1.0", + "micromatch": "3.1.5", + "resolve-dir": "1.0.1" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "braces": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.0.tgz", + "integrity": "sha512-P4O8UQRdGiMLWSizsApmXVQDBS6KCt7dSexgLKBmH5Hr1CZq7vsnscFh8oR1sP1ab1Zj0uCHCEzZeV6SfUf3rA==", + "dev": true, + "requires": { + "arr-flatten": "1.1.0", + "array-unique": "0.3.2", + "define-property": "1.0.0", + "extend-shallow": "2.0.1", + "fill-range": "4.0.0", + "isobject": "3.0.1", + "repeat-element": "1.1.2", + "snapdragon": "0.8.1", + "snapdragon-node": "2.1.1", + "split-string": "3.1.0", + "to-regex": "3.0.1" + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "2.6.9", + "define-property": "0.2.5", + "extend-shallow": "2.0.1", + "posix-character-classes": "0.1.1", + "regex-not": "1.0.0", + "snapdragon": "0.8.1", + "to-regex": "3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + } + } + }, + "extglob": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.3.tgz", + "integrity": "sha512-AyptZexgu7qppEPq59DtN/XJGZDrLcVxSHai+4hdgMMS9EpF4GBvygcWWApno8lL9qSjVpYt7Raao28qzJX1ww==", + "dev": true, + "requires": { + "array-unique": "0.3.2", + "define-property": "1.0.0", + "expand-brackets": "2.1.4", + "extend-shallow": "2.0.1", + "fragment-cache": "0.2.1", + "regex-not": "1.0.0", + "snapdragon": "0.8.1", + "to-regex": "3.0.1" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "2.0.1", + "is-number": "3.0.0", + "repeat-string": "1.6.1", + "to-regex-range": "2.1.1" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "2.1.1" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "micromatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.5.tgz", + "integrity": "sha512-ykttrLPQrz1PUJcXjwsTUjGoPJ64StIGNE2lGVD1c9CuguJ+L7/navsE8IcDNndOoCMvYV0qc/exfVbMHkUhvA==", + "dev": true, + "requires": { + "arr-diff": "4.0.0", + "array-unique": "0.3.2", + "braces": "2.3.0", + "define-property": "1.0.0", + "extend-shallow": "2.0.1", + "extglob": "2.0.3", + "fragment-cache": "0.2.1", + "kind-of": "6.0.2", + "nanomatch": "1.2.7", + "object.pick": "1.3.0", + "regex-not": "1.0.0", + "snapdragon": "0.8.1", + "to-regex": "3.0.1" + } + } + } + }, + "fined": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-1.1.0.tgz", + "integrity": "sha1-s33IRLdqL15wgeiE98CuNE8VNHY=", + "dev": true, + "requires": { + "expand-tilde": "2.0.2", + "is-plain-object": "2.0.4", + "object.defaults": "1.1.0", + "object.pick": "1.3.0", + "parse-filepath": "1.0.2" + } + }, + "first-chunk-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-0.1.0.tgz", + "integrity": "sha1-dV0+wU1JqG49L8wIvurVwMornAo=" + }, + "flagged-respawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.0.tgz", + "integrity": "sha1-Tnmumy6zi/hrO7Vr8+ClaqX8q9c=", + "dev": true + }, + "flatiron": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/flatiron/-/flatiron-0.4.3.tgz", + "integrity": "sha1-JIz3mj2n19w3nioRySonGcu1QPY=", + "requires": { + "broadway": "0.3.6", + "director": "1.2.7", + "optimist": "0.6.0", + "prompt": "0.2.14" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + }, + "optimist": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.0.tgz", + "integrity": "sha1-aUJIJvNAX3nxQub8PZrljU27kgA=", + "requires": { + "minimist": "0.0.10", + "wordwrap": "0.0.3" + } + } + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=" + }, + "for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", + "requires": { + "for-in": "1.0.2" + } + }, + "forever": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/forever/-/forever-0.15.3.tgz", + "integrity": "sha1-d9nX4V/S9RGtnYShEMfdj8js68I=", + "requires": { + "cliff": "0.1.10", + "clone": "1.0.3", + "colors": "0.6.2", + "flatiron": "0.4.3", + "forever-monitor": "1.7.1", + "nconf": "0.6.9", + "nssocket": "0.5.3", + "object-assign": "3.0.0", + "optimist": "0.6.1", + "path-is-absolute": "1.0.1", + "prettyjson": "1.2.1", + "shush": "1.0.0", + "timespan": "2.3.0", + "utile": "0.2.1", + "winston": "0.8.3" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, + "winston": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-0.8.3.tgz", + "integrity": "sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA=", + "requires": { + "async": "0.2.10", + "colors": "0.6.2", + "cycle": "1.0.3", + "eyes": "0.1.8", + "isstream": "0.1.2", + "pkginfo": "0.3.1", + "stack-trace": "0.0.10" + } + } + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "forever-monitor": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/forever-monitor/-/forever-monitor-1.7.1.tgz", + "integrity": "sha1-XYIPSjp42y2BriZx8Vi56GoJG7g=", + "requires": { + "broadway": "0.3.6", + "chokidar": "1.7.0", + "minimatch": "3.0.4", + "ps-tree": "0.0.3", + "utile": "0.2.1" + } + }, + "form-data": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.1.tgz", + "integrity": "sha1-b7lPvXGIUwbXPRXMSX/kzE7NRL8=", + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.5", + "mime-types": "2.1.17" + } + }, + "format-util": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.3.tgz", + "integrity": "sha1-Ay3KShFiYqEsQ/TD7IVmQWxbLZU=", + "dev": true + }, + "formatio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.2.0.tgz", + "integrity": "sha1-87IWfZBoxGmKjVH092CjmlTYGOs=", + "dev": true, + "requires": { + "samsam": "1.3.0" + } + }, + "formidable": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.1.1.tgz", + "integrity": "sha1-lriIb3w8NQi5Mta9cMTTqI818ak=" + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "0.2.2" + } + }, + "frameguard": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/frameguard/-/frameguard-3.0.0.tgz", + "integrity": "sha1-e8rUae57lukdEs6zlZx4I1qScuk=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs-exists-sync": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz", + "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fsevents": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.3.tgz", + "integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==", + "optional": true, + "requires": { + "nan": "2.9.2", + "node-pre-gyp": "0.6.39" + }, + "dependencies": { + "abbrev": { + "version": "1.1.0", + "bundled": true, + "optional": true + }, + "ajv": { + "version": "4.11.8", + "bundled": true, + "optional": true, + "requires": { + "co": "4.6.0", + "json-stable-stringify": "1.0.1" + } + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true + }, + "aproba": { + "version": "1.1.1", + "bundled": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "optional": true, + "requires": { + "delegates": "1.0.0", + "readable-stream": "2.2.9" + } + }, + "asn1": { + "version": "0.2.3", + "bundled": true, + "optional": true + }, + "assert-plus": { + "version": "0.2.0", + "bundled": true, + "optional": true + }, + "asynckit": { + "version": "0.4.0", + "bundled": true, + "optional": true + }, + "aws-sign2": { + "version": "0.6.0", + "bundled": true, + "optional": true + }, + "aws4": { + "version": "1.6.0", + "bundled": true, + "optional": true + }, + "balanced-match": { + "version": "0.4.2", + "bundled": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "bundled": true, + "optional": true, + "requires": { + "tweetnacl": "0.14.5" + } + }, + "block-stream": { + "version": "0.0.9", + "bundled": true, + "requires": { + "inherits": "2.0.3" + } + }, + "boom": { + "version": "2.10.1", + "bundled": true, + "requires": { + "hoek": "2.16.3" + } + }, + "brace-expansion": { + "version": "1.1.7", + "bundled": true, + "requires": { + "balanced-match": "0.4.2", + "concat-map": "0.0.1" + } + }, + "buffer-shims": { + "version": "1.0.0", + "bundled": true + }, + "caseless": { + "version": "0.12.0", + "bundled": true, + "optional": true + }, + "co": { + "version": "4.6.0", + "bundled": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true + }, + "combined-stream": { + "version": "1.0.5", + "bundled": true, + "requires": { + "delayed-stream": "1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true + }, + "cryptiles": { + "version": "2.0.5", + "bundled": true, + "requires": { + "boom": "2.10.1" + } + }, + "dashdash": { + "version": "1.14.1", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "debug": { + "version": "2.6.8", + "bundled": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.4.2", + "bundled": true, + "optional": true + }, + "delayed-stream": { + "version": "1.0.0", + "bundled": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.2", + "bundled": true, + "optional": true + }, + "ecc-jsbn": { + "version": "0.1.1", + "bundled": true, + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "extend": { + "version": "3.0.1", + "bundled": true, + "optional": true + }, + "extsprintf": { + "version": "1.0.2", + "bundled": true + }, + "forever-agent": { + "version": "0.6.1", + "bundled": true, + "optional": true + }, + "form-data": { + "version": "2.1.4", + "bundled": true, + "optional": true, + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.5", + "mime-types": "2.1.15" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true + }, + "fstream": { + "version": "1.0.11", + "bundled": true, + "requires": { + "graceful-fs": "4.1.11", + "inherits": "2.0.3", + "mkdirp": "0.5.1", + "rimraf": "2.6.1" + } + }, + "fstream-ignore": { + "version": "1.0.5", + "bundled": true, + "optional": true, + "requires": { + "fstream": "1.0.11", + "inherits": "2.0.3", + "minimatch": "3.0.4" + } + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "optional": true, + "requires": { + "aproba": "1.1.1", + "console-control-strings": "1.1.0", + "has-unicode": "2.0.1", + "object-assign": "4.1.1", + "signal-exit": "3.0.2", + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wide-align": "1.1.2" + } + }, + "getpass": { + "version": "0.1.7", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "graceful-fs": { + "version": "4.1.11", + "bundled": true + }, + "har-schema": { + "version": "1.0.5", + "bundled": true, + "optional": true + }, + "har-validator": { + "version": "4.2.1", + "bundled": true, + "optional": true, + "requires": { + "ajv": "4.11.8", + "har-schema": "1.0.5" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "optional": true + }, + "hawk": { + "version": "3.1.3", + "bundled": true, + "requires": { + "boom": "2.10.1", + "cryptiles": "2.0.5", + "hoek": "2.16.3", + "sntp": "1.0.9" + } + }, + "hoek": { + "version": "2.16.3", + "bundled": true + }, + "http-signature": { + "version": "1.1.1", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "0.2.0", + "jsprim": "1.4.0", + "sshpk": "1.13.0" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true + }, + "ini": { + "version": "1.3.4", + "bundled": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "bundled": true, + "optional": true + }, + "isarray": { + "version": "1.0.0", + "bundled": true + }, + "isstream": { + "version": "0.1.2", + "bundled": true, + "optional": true + }, + "jodid25519": { + "version": "1.0.2", + "bundled": true, + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "jsbn": { + "version": "0.1.1", + "bundled": true, + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "bundled": true, + "optional": true + }, + "json-stable-stringify": { + "version": "1.0.1", + "bundled": true, + "optional": true, + "requires": { + "jsonify": "0.0.0" + } + }, + "json-stringify-safe": { + "version": "5.0.1", + "bundled": true, + "optional": true + }, + "jsonify": { + "version": "0.0.0", + "bundled": true, + "optional": true + }, + "jsprim": { + "version": "1.4.0", + "bundled": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.0.2", + "json-schema": "0.2.3", + "verror": "1.3.6" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "mime-db": { + "version": "1.27.0", + "bundled": true + }, + "mime-types": { + "version": "2.1.15", + "bundled": true, + "requires": { + "mime-db": "1.27.0" + } + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "requires": { + "brace-expansion": "1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "optional": true + }, + "node-pre-gyp": { + "version": "0.6.39", + "bundled": true, + "optional": true, + "requires": { + "detect-libc": "1.0.2", + "hawk": "3.1.3", + "mkdirp": "0.5.1", + "nopt": "4.0.1", + "npmlog": "4.1.0", + "rc": "1.2.1", + "request": "2.81.0", + "rimraf": "2.6.1", + "semver": "5.3.0", + "tar": "2.2.1", + "tar-pack": "3.4.0" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "optional": true, + "requires": { + "abbrev": "1.1.0", + "osenv": "0.1.4" + } + }, + "npmlog": { + "version": "4.1.0", + "bundled": true, + "optional": true, + "requires": { + "are-we-there-yet": "1.1.4", + "console-control-strings": "1.1.0", + "gauge": "2.7.4", + "set-blocking": "2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true + }, + "oauth-sign": { + "version": "0.8.2", + "bundled": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "optional": true + }, + "osenv": { + "version": "0.1.4", + "bundled": true, + "optional": true, + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true + }, + "performance-now": { + "version": "0.2.0", + "bundled": true, + "optional": true + }, + "process-nextick-args": { + "version": "1.0.7", + "bundled": true + }, + "punycode": { + "version": "1.4.1", + "bundled": true, + "optional": true + }, + "qs": { + "version": "6.4.0", + "bundled": true, + "optional": true + }, + "rc": { + "version": "1.2.1", + "bundled": true, + "optional": true, + "requires": { + "deep-extend": "0.4.2", + "ini": "1.3.4", + "minimist": "1.2.0", + "strip-json-comments": "2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.2.9", + "bundled": true, + "requires": { + "buffer-shims": "1.0.0", + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "string_decoder": "1.0.1", + "util-deprecate": "1.0.2" + } + }, + "request": { + "version": "2.81.0", + "bundled": true, + "optional": true, + "requires": { + "aws-sign2": "0.6.0", + "aws4": "1.6.0", + "caseless": "0.12.0", + "combined-stream": "1.0.5", + "extend": "3.0.1", + "forever-agent": "0.6.1", + "form-data": "2.1.4", + "har-validator": "4.2.1", + "hawk": "3.1.3", + "http-signature": "1.1.1", + "is-typedarray": "1.0.0", + "isstream": "0.1.2", + "json-stringify-safe": "5.0.1", + "mime-types": "2.1.15", + "oauth-sign": "0.8.2", + "performance-now": "0.2.0", + "qs": "6.4.0", + "safe-buffer": "5.0.1", + "stringstream": "0.0.5", + "tough-cookie": "2.3.2", + "tunnel-agent": "0.6.0", + "uuid": "3.0.1" + } + }, + "rimraf": { + "version": "2.6.1", + "bundled": true, + "requires": { + "glob": "7.1.2" + } + }, + "safe-buffer": { + "version": "5.0.1", + "bundled": true + }, + "semver": { + "version": "5.3.0", + "bundled": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "optional": true + }, + "sntp": { + "version": "1.0.9", + "bundled": true, + "requires": { + "hoek": "2.16.3" + } + }, + "sshpk": { + "version": "1.13.0", + "bundled": true, + "optional": true, + "requires": { + "asn1": "0.2.3", + "assert-plus": "1.0.0", + "bcrypt-pbkdf": "1.0.1", + "dashdash": "1.14.1", + "ecc-jsbn": "0.1.1", + "getpass": "0.1.7", + "jodid25519": "1.0.2", + "jsbn": "0.1.1", + "tweetnacl": "0.14.5" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "optional": true + } + } + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + }, + "string_decoder": { + "version": "1.0.1", + "bundled": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, + "stringstream": { + "version": "0.0.5", + "bundled": true, + "optional": true + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "optional": true + }, + "tar": { + "version": "2.2.1", + "bundled": true, + "requires": { + "block-stream": "0.0.9", + "fstream": "1.0.11", + "inherits": "2.0.3" + } + }, + "tar-pack": { + "version": "3.4.0", + "bundled": true, + "optional": true, + "requires": { + "debug": "2.6.8", + "fstream": "1.0.11", + "fstream-ignore": "1.0.5", + "once": "1.4.0", + "readable-stream": "2.2.9", + "rimraf": "2.6.1", + "tar": "2.2.1", + "uid-number": "0.0.6" + } + }, + "tough-cookie": { + "version": "2.3.2", + "bundled": true, + "optional": true, + "requires": { + "punycode": "1.4.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "bundled": true, + "optional": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "bundled": true, + "optional": true + }, + "uid-number": { + "version": "0.0.6", + "bundled": true, + "optional": true + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true + }, + "uuid": { + "version": "3.0.1", + "bundled": true, + "optional": true + }, + "verror": { + "version": "1.3.6", + "bundled": true, + "optional": true, + "requires": { + "extsprintf": "1.0.2" + } + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "optional": true, + "requires": { + "string-width": "1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true + } + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "gaze": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-0.5.2.tgz", + "integrity": "sha1-QLcJU30k0dRXZ9takIaJ3+aaxE8=", + "dev": true, + "requires": { + "globule": "0.1.0" + } + }, + "get-caller-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz", + "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "get-object": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/get-object/-/get-object-0.2.0.tgz", + "integrity": "sha1-2S/31RkMZFMM2gVD2sY6PUf+jAw=", + "dev": true, + "requires": { + "is-number": "2.1.0", + "isobject": "0.2.0" + }, + "dependencies": { + "isobject": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-0.2.0.tgz", + "integrity": "sha1-o0MhkvObkQtfAsyYlIeDbscKqF4=", + "dev": true + } + } + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "1.0.0" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "glob-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=", + "requires": { + "glob-parent": "2.0.0", + "is-glob": "2.0.1" + } + }, + "glob-parent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", + "requires": { + "is-glob": "2.0.1" + } + }, + "glob-stream": { + "version": "3.1.18", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-3.1.18.tgz", + "integrity": "sha1-kXCl8St5Awb9/lmPMT+PeVT9FDs=", + "dev": true, + "requires": { + "glob": "4.5.3", + "glob2base": "0.0.12", + "minimatch": "2.0.10", + "ordered-read-streams": "0.1.0", + "through2": "0.6.5", + "unique-stream": "1.0.0" + }, + "dependencies": { + "glob": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz", + "integrity": "sha1-xstz0yJsHv7wTePFbQEvAzd+4V8=", + "dev": true, + "requires": { + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "2.0.10", + "once": "1.4.0" + } + }, + "minimatch": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz", + "integrity": "sha1-jQh8OcazjAAbl/ynzm0OHoCvusc=", + "dev": true, + "requires": { + "brace-expansion": "1.1.8" + } + } + } + }, + "glob-watcher": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-0.0.6.tgz", + "integrity": "sha1-uVtKjfdLOcgymLDAXJeLTZo7cQs=", + "dev": true, + "requires": { + "gaze": "0.5.2" + } + }, + "glob2base": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz", + "integrity": "sha1-nUGbPijxLoOjYhZKJ3BVkiycDVY=", + "dev": true, + "requires": { + "find-index": "0.1.1" + } + }, + "global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "requires": { + "global-prefix": "1.0.2", + "is-windows": "1.0.1", + "resolve-dir": "1.0.1" + } + }, + "global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=", + "dev": true, + "requires": { + "expand-tilde": "2.0.2", + "homedir-polyfill": "1.0.1", + "ini": "1.3.5", + "is-windows": "1.0.1", + "which": "1.3.0" + } + }, + "globule": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globule/-/globule-0.1.0.tgz", + "integrity": "sha1-2cjt3h2nnRJaFRt5UzuXhnY0auU=", + "dev": true, + "requires": { + "glob": "3.1.21", + "lodash": "1.0.2", + "minimatch": "0.2.14" + }, + "dependencies": { + "glob": { + "version": "3.1.21", + "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz", + "integrity": "sha1-0p4KBV3qUTj00H7UDomC6DwgZs0=", + "dev": true, + "requires": { + "graceful-fs": "1.2.3", + "inherits": "1.0.2", + "minimatch": "0.2.14" + } + }, + "graceful-fs": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz", + "integrity": "sha1-FaSAaldUfLLS2/J/QuiajDRRs2Q=", + "dev": true + }, + "inherits": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz", + "integrity": "sha1-ykMJ2t7mtUzAuNJH6NfHoJdb3Js=", + "dev": true + }, + "lodash": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz", + "integrity": "sha1-j1dWDIO1n8JwvT1WG2kAQ0MOJVE=", + "dev": true + }, + "lru-cache": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", + "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=", + "dev": true + }, + "minimatch": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz", + "integrity": "sha1-x054BXT2PG+aCQ6Q775u9TpqdWo=", + "dev": true, + "requires": { + "lru-cache": "2.7.3", + "sigmund": "1.0.1" + } + } + } + }, + "glogg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.0.tgz", + "integrity": "sha1-f+DxmfV6yQbPUS/urY+Q7kooT8U=", + "dev": true, + "requires": { + "sparkles": "1.0.0" + } + }, + "gm": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/gm/-/gm-1.23.1.tgz", + "integrity": "sha1-Lt7rlYCE0PjqeYjl2ZWxx9/BR3c=", + "requires": { + "array-parallel": "0.1.3", + "array-series": "0.1.5", + "cross-spawn": "4.0.2", + "debug": "3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" + }, + "graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=" + }, + "graphlib": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.5.tgz", + "integrity": "sha512-XvtbqCcw+EM5SqQrIetIKKD+uZVNQtDPD1goIg7K73RuRZtVI5rYMdcCVSHm/AS1sCBZ7vt0p5WgXouucHQaOA==", + "requires": { + "lodash": "4.17.4" + } + }, + "gulp": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-3.9.1.tgz", + "integrity": "sha1-VxzkWSjdQK9lFPxAEYZgFsE4RbQ=", + "dev": true, + "requires": { + "archy": "1.0.0", + "chalk": "1.1.3", + "deprecated": "0.0.1", + "gulp-util": "3.0.8", + "interpret": "1.1.0", + "liftoff": "2.5.0", + "minimist": "1.2.0", + "orchestrator": "0.3.8", + "pretty-hrtime": "1.0.3", + "semver": "4.3.6", + "tildify": "1.2.0", + "v8flags": "2.1.1", + "vinyl-fs": "0.3.14" + } + }, + "gulp-bump": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/gulp-bump/-/gulp-bump-2.9.0.tgz", + "integrity": "sha512-Cu+QOhwb2Jr2K6yo2u2mh4GWQRpSAMZD/z0v8FStlrOGaqML9u1On7XcyR1pS/PN3HQ9wsd/Ks6AcCQb+j3BgA==", + "dev": true, + "requires": { + "bump-regex": "2.9.0", + "plugin-error": "0.1.2", + "plugin-log": "0.1.0", + "semver": "5.4.1", + "through2": "2.0.3" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "2.3.3", + "xtend": "4.0.1" + } + } + } + }, + "gulp-load-plugins": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/gulp-load-plugins/-/gulp-load-plugins-1.5.0.tgz", + "integrity": "sha1-TEGffldk2aDjMGG6uWGPgbc9QXE=", + "dev": true, + "requires": { + "array-unique": "0.2.1", + "fancy-log": "1.3.2", + "findup-sync": "0.4.3", + "gulplog": "1.0.0", + "has-gulplog": "0.1.0", + "micromatch": "2.3.11", + "resolve": "1.5.0" + }, + "dependencies": { + "detect-file": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-0.1.0.tgz", + "integrity": "sha1-STXe39lIhkjgBrASlWbpOGcR6mM=", + "dev": true, + "requires": { + "fs-exists-sync": "0.1.0" + } + }, + "expand-tilde": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-1.2.2.tgz", + "integrity": "sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=", + "dev": true, + "requires": { + "os-homedir": "1.0.2" + } + }, + "findup-sync": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.4.3.tgz", + "integrity": "sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=", + "dev": true, + "requires": { + "detect-file": "0.1.0", + "is-glob": "2.0.1", + "micromatch": "2.3.11", + "resolve-dir": "0.1.1" + } + }, + "global-modules": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-0.2.3.tgz", + "integrity": "sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=", + "dev": true, + "requires": { + "global-prefix": "0.1.5", + "is-windows": "0.2.0" + } + }, + "global-prefix": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-0.1.5.tgz", + "integrity": "sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=", + "dev": true, + "requires": { + "homedir-polyfill": "1.0.1", + "ini": "1.3.5", + "is-windows": "0.2.0", + "which": "1.3.0" + } + }, + "is-windows": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-0.2.0.tgz", + "integrity": "sha1-3hqm1j6indJIc3tp8f+LgALSEIw=", + "dev": true + }, + "resolve-dir": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-0.1.1.tgz", + "integrity": "sha1-shklmlYC+sXFxJatiUpujMQwJh4=", + "dev": true, + "requires": { + "expand-tilde": "1.2.2", + "global-modules": "0.2.3" + } + } + } + }, + "gulp-plumber": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gulp-plumber/-/gulp-plumber-1.2.0.tgz", + "integrity": "sha512-L/LJftsbKoHbVj6dN5pvMsyJn9jYI0wT0nMg3G6VZhDac4NesezecYTi8/48rHi+yEic3sUpw6jlSc7qNWh32A==", + "dev": true, + "requires": { + "chalk": "1.1.3", + "fancy-log": "1.3.2", + "plugin-error": "0.1.2", + "through2": "2.0.3" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "2.3.3", + "xtend": "4.0.1" + } + } + } + }, + "gulp-print": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/gulp-print/-/gulp-print-2.0.1.tgz", + "integrity": "sha1-Gs7ljqyK8tPErTMp2+RldYOTxBQ=", + "dev": true, + "requires": { + "gulp-util": "3.0.8", + "map-stream": "0.0.7" + } + }, + "gulp-spawn-mocha": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/gulp-spawn-mocha/-/gulp-spawn-mocha-5.0.0.tgz", + "integrity": "sha512-XtGtPW80uimSUUa/NG1drMWGP66uEVA0jwbOFge+qqVVa4zxVJrAJOlz7/4GoxZ69Bfohb9nGBc402Z+ovngPQ==", + "dev": true, + "requires": { + "gulp-util": "3.0.8", + "lodash": "4.17.4", + "through": "2.3.8" + } + }, + "gulp-task-listing": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gulp-task-listing/-/gulp-task-listing-1.0.1.tgz", + "integrity": "sha1-jT2IqTOBcV2A1m0I2cVVh9iJ8ro=", + "dev": true, + "requires": { + "chalk": "1.1.3" + } + }, + "gulp-util": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.8.tgz", + "integrity": "sha1-AFTh50RQLifATBh8PsxQXdVLu08=", + "dev": true, + "requires": { + "array-differ": "1.0.0", + "array-uniq": "1.0.3", + "beeper": "1.1.1", + "chalk": "1.1.3", + "dateformat": "2.2.0", + "fancy-log": "1.3.2", + "gulplog": "1.0.0", + "has-gulplog": "0.1.0", + "lodash._reescape": "3.0.0", + "lodash._reevaluate": "3.0.0", + "lodash._reinterpolate": "3.0.0", + "lodash.template": "3.6.2", + "minimist": "1.2.0", + "multipipe": "0.1.2", + "object-assign": "3.0.0", + "replace-ext": "0.0.1", + "through2": "2.0.3", + "vinyl": "0.5.3" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "2.3.3", + "xtend": "4.0.1" + } + } + } + }, + "gulp-watch": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/gulp-watch/-/gulp-watch-4.3.11.tgz", + "integrity": "sha1-Fi/FY96fx3DpH5p845VVE6mhGMA=", + "dev": true, + "requires": { + "anymatch": "1.3.2", + "chokidar": "1.7.0", + "glob-parent": "3.1.0", + "gulp-util": "3.0.8", + "object-assign": "4.1.1", + "path-is-absolute": "1.0.1", + "readable-stream": "2.3.3", + "slash": "1.0.0", + "vinyl": "1.2.0", + "vinyl-file": "2.0.0" + }, + "dependencies": { + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "3.1.0", + "path-dirname": "1.0.2" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "2.1.1" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "vinyl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.2.0.tgz", + "integrity": "sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=", + "dev": true, + "requires": { + "clone": "1.0.3", + "clone-stats": "0.0.1", + "replace-ext": "0.0.1" + } + } + } + }, + "gulplog": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz", + "integrity": "sha1-4oxNRdBey77YGDY86PnFkmIp/+U=", + "dev": true, + "requires": { + "glogg": "1.0.0" + } + }, + "handlebars": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", + "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "requires": { + "async": "1.5.2", + "optimist": "0.6.1", + "source-map": "0.4.4", + "uglify-js": "2.8.29" + } + }, + "handlebars-helper-create-frame": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/handlebars-helper-create-frame/-/handlebars-helper-create-frame-0.1.0.tgz", + "integrity": "sha1-iqUdEK62QI/MZgXUDXc1YohIegM=", + "dev": true, + "requires": { + "create-frame": "1.0.0", + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "handlebars-helpers": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/handlebars-helpers/-/handlebars-helpers-0.9.8.tgz", + "integrity": "sha512-N9MoNopXTOzNv9L2oDFUo1ZhWTzUd8YURVrksZaXVRybgs1JFnUXohCnFTOJL8m4t+jKn1xU6Vi7qxtCu4mRsg==", + "dev": true, + "requires": { + "arr-flatten": "1.1.0", + "array-sort": "0.1.4", + "create-frame": "1.0.0", + "define-property": "1.0.0", + "falsey": "0.3.2", + "for-in": "1.0.2", + "for-own": "1.0.0", + "get-object": "0.2.0", + "get-value": "2.0.6", + "handlebars": "4.0.11", + "handlebars-helper-create-frame": "0.1.0", + "handlebars-utils": "1.0.6", + "has-value": "1.0.0", + "helper-date": "1.0.1", + "helper-markdown": "0.2.2", + "helper-md": "0.2.2", + "html-tag": "1.0.0", + "is-even": "1.0.0", + "is-glob": "3.1.0", + "is-number": "3.0.0", + "kind-of": "5.1.0", + "lazy-cache": "2.0.2", + "logging-helpers": "1.0.0", + "micromatch": "3.1.5", + "relative": "3.0.2", + "striptags": "3.1.1", + "to-gfm-code-block": "0.1.1", + "year": "0.2.1" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "braces": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.0.tgz", + "integrity": "sha512-P4O8UQRdGiMLWSizsApmXVQDBS6KCt7dSexgLKBmH5Hr1CZq7vsnscFh8oR1sP1ab1Zj0uCHCEzZeV6SfUf3rA==", + "dev": true, + "requires": { + "arr-flatten": "1.1.0", + "array-unique": "0.3.2", + "define-property": "1.0.0", + "extend-shallow": "2.0.1", + "fill-range": "4.0.0", + "isobject": "3.0.1", + "repeat-element": "1.1.2", + "snapdragon": "0.8.1", + "snapdragon-node": "2.1.1", + "split-string": "3.1.0", + "to-regex": "3.0.1" + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "2.6.9", + "define-property": "0.2.5", + "extend-shallow": "2.0.1", + "posix-character-classes": "0.1.1", + "regex-not": "1.0.0", + "snapdragon": "0.8.1", + "to-regex": "3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + } + } + }, + "extglob": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.3.tgz", + "integrity": "sha512-AyptZexgu7qppEPq59DtN/XJGZDrLcVxSHai+4hdgMMS9EpF4GBvygcWWApno8lL9qSjVpYt7Raao28qzJX1ww==", + "dev": true, + "requires": { + "array-unique": "0.3.2", + "define-property": "1.0.0", + "expand-brackets": "2.1.4", + "extend-shallow": "2.0.1", + "fragment-cache": "0.2.1", + "regex-not": "1.0.0", + "snapdragon": "0.8.1", + "to-regex": "3.0.1" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "2.0.1", + "is-number": "3.0.0", + "repeat-string": "1.6.1", + "to-regex-range": "2.1.1" + } + }, + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "dev": true, + "requires": { + "for-in": "1.0.2" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "2.1.1" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + }, + "lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=", + "dev": true, + "requires": { + "set-getter": "0.1.0" + } + }, + "micromatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.5.tgz", + "integrity": "sha512-ykttrLPQrz1PUJcXjwsTUjGoPJ64StIGNE2lGVD1c9CuguJ+L7/navsE8IcDNndOoCMvYV0qc/exfVbMHkUhvA==", + "dev": true, + "requires": { + "arr-diff": "4.0.0", + "array-unique": "0.3.2", + "braces": "2.3.0", + "define-property": "1.0.0", + "extend-shallow": "2.0.1", + "extglob": "2.0.3", + "fragment-cache": "0.2.1", + "kind-of": "6.0.2", + "nanomatch": "1.2.7", + "object.pick": "1.3.0", + "regex-not": "1.0.0", + "snapdragon": "0.8.1", + "to-regex": "3.0.1" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + } + } + }, + "handlebars-utils": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/handlebars-utils/-/handlebars-utils-1.0.6.tgz", + "integrity": "sha512-d5mmoQXdeEqSKMtQQZ9WkiUcO1E3tPbWxluCK9hVgIDPzQa9WsKo3Lbe/sGflTe7TomHEeZaOgwIkyIr1kfzkw==", + "dev": true, + "requires": { + "kind-of": "6.0.2", + "typeof-article": "0.1.1" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "5.5.2", + "har-schema": "2.0.0" + } + }, + "has": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz", + "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=", + "requires": { + "function-bind": "1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "has-color": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/has-color/-/has-color-0.1.7.tgz", + "integrity": "sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=", + "dev": true + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "has-gulplog": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/has-gulplog/-/has-gulplog-0.1.0.tgz", + "integrity": "sha1-ZBTIKRNpfaUVkDl9r7EvIpZ4Ec4=", + "dev": true, + "requires": { + "sparkles": "1.0.0" + } + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "2.0.6", + "has-values": "1.0.0", + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "3.0.0", + "kind-of": "4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "hawk": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", + "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", + "requires": { + "boom": "4.3.1", + "cryptiles": "3.1.2", + "hoek": "4.2.0", + "sntp": "2.1.0" + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "helmet": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-3.9.0.tgz", + "integrity": "sha512-czCyS77TyanWlfVSoGlb9GBJV2Q2zJayKxU5uBw0N1TzDTs/qVNh1SL8Q688KU0i0Sb7lQ/oLtnaEqXzl2yWvA==", + "requires": { + "dns-prefetch-control": "0.1.0", + "dont-sniff-mimetype": "1.0.0", + "expect-ct": "0.1.0", + "frameguard": "3.0.0", + "helmet-csp": "2.6.0", + "hide-powered-by": "1.0.0", + "hpkp": "2.0.0", + "hsts": "2.1.0", + "ienoopen": "1.0.0", + "nocache": "2.0.0", + "referrer-policy": "1.1.0", + "x-xss-protection": "1.0.0" + } + }, + "helmet-csp": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/helmet-csp/-/helmet-csp-2.6.0.tgz", + "integrity": "sha512-n/oW9l6RtO4f9YvphsNzdvk1zITrSN7iRT8ojgrJu/N3mVdHl9zE4OjbiHWcR64JK32kbqx90/yshWGXcjUEhw==", + "requires": { + "camelize": "1.0.0", + "content-security-policy-builder": "1.1.0", + "dasherize": "2.0.0", + "lodash.reduce": "4.6.0", + "platform": "1.3.4" + } + }, + "helper-date": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/helper-date/-/helper-date-1.0.1.tgz", + "integrity": "sha512-wU3VOwwTJvGr/w5rZr3cprPHO+hIhlblTJHD6aFBrKLuNbf4lAmkawd2iK3c6NbJEvY7HAmDpqjOFSI5/+Ey2w==", + "dev": true, + "requires": { + "date.js": "0.3.2", + "handlebars-utils": "1.0.6", + "moment": "2.20.1" + } + }, + "helper-markdown": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/helper-markdown/-/helper-markdown-0.2.2.tgz", + "integrity": "sha1-ONt/dxhJ4wrpXJL8AhuutT8uMEA=", + "dev": true, + "requires": { + "isobject": "2.1.0", + "mixin-deep": "1.3.0", + "remarkable": "1.7.1" + } + }, + "helper-md": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/helper-md/-/helper-md-0.2.2.tgz", + "integrity": "sha1-wfWdflW7riM2L9ig6XFgeuxp1B8=", + "dev": true, + "requires": { + "ent": "2.2.0", + "extend-shallow": "2.0.1", + "fs-exists-sync": "0.1.0", + "remarkable": "1.7.1" + } + }, + "hide-powered-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hide-powered-by/-/hide-powered-by-1.0.0.tgz", + "integrity": "sha1-SoWtZYgfYoV/xwr3F0oRhNzM4ys=" + }, + "hoek": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.0.tgz", + "integrity": "sha512-v0XCLxICi9nPfYrS9RL8HbYnXi9obYAeLbSP00BmnZwCK9+Ih9WOjoZ8YoHCoav2csqn4FOz4Orldsy2dmDwmQ==" + }, + "homedir-polyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz", + "integrity": "sha1-TCu8inWJmP7r9e1oWA921GdotLw=", + "dev": true, + "requires": { + "parse-passwd": "1.0.0" + } + }, + "hosted-git-info": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.5.0.tgz", + "integrity": "sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg==", + "dev": true + }, + "hpkp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hpkp/-/hpkp-2.0.0.tgz", + "integrity": "sha1-EOFCJk52IVpdMMROxD3mTe5tFnI=" + }, + "hsts": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hsts/-/hsts-2.1.0.tgz", + "integrity": "sha512-zXhh/DqgrTXJ7erTN6Fh5k/xjMhDGXCqdYN3wvxUvGUQvnxcFfUd8E+6vLg/nk3ss1TYMb+DhRl25fYABioTvA==" + }, + "html-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/html-tag/-/html-tag-1.0.0.tgz", + "integrity": "sha1-leVhKuyCvqko7URZX4VBRen34LU=", + "dev": true, + "requires": { + "isobject": "3.0.1", + "void-elements": "2.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "htmlparser2": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", + "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", + "dev": true, + "requires": { + "domelementtype": "1.3.0", + "domhandler": "2.3.0", + "domutils": "1.5.1", + "entities": "1.0.0", + "readable-stream": "1.1.14" + }, + "dependencies": { + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + } + } + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": "1.4.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "1.0.0", + "jsprim": "1.4.1", + "sshpk": "1.13.1" + } + }, + "http-status-codes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-1.3.0.tgz", + "integrity": "sha1-nNDnE5F3PQZxtInUHLxQlKpBY7Y=" + }, + "i": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/i/-/i-0.3.6.tgz", + "integrity": "sha1-2WyScyB28HJxG2sQ/X1PZa2O4j0=" + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" + }, + "ideal-postcodes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ideal-postcodes/-/ideal-postcodes-1.0.0.tgz", + "integrity": "sha1-/uAwYLkEdTykir/JoM/03x+q0BQ=", + "requires": { + "lodash": "4.17.4", + "qs": "6.4.0" + }, + "dependencies": { + "qs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz", + "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=" + } + } + }, + "ienoopen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ienoopen/-/ienoopen-1.0.0.tgz", + "integrity": "sha1-NGpCj0dKrI9QzzeE6i0PFvYr2ms=" + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "2.0.1" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "info-symbol": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/info-symbol/-/info-symbol-0.1.0.tgz", + "integrity": "sha1-J4QdcoZ920JCzWEtecEGM4gcang=", + "dev": true + }, + "inherit": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/inherit/-/inherit-2.2.6.tgz", + "integrity": "sha1-8WFLBshUToEo5CKchjR9tzrZeI0=", + "dev": true + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", + "dev": true + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "dev": true + }, + "ipaddr.js": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.5.2.tgz", + "integrity": "sha1-1LUFvemUaYfM8PxY2QEP+WB+P6A=" + }, + "is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "requires": { + "is-relative": "1.0.0", + "is-windows": "1.0.1" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "requires": { + "binary-extensions": "1.11.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "dev": true, + "requires": { + "builtin-modules": "1.1.1" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "1.0.0", + "is-data-descriptor": "1.0.0", + "kind-of": "6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "is-dotfile": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", + "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=" + }, + "is-equal-shallow": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=", + "requires": { + "is-primitive": "2.0.0" + } + }, + "is-even": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-even/-/is-even-1.0.0.tgz", + "integrity": "sha1-drUFX7rY0pSoa2qUkBXhyXtxfAY=", + "dev": true, + "requires": { + "is-odd": "0.1.2" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "is-odd": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-0.1.2.tgz", + "integrity": "sha1-vFc7XONx7yqtbm9JeZtyvvE5eKc=", + "dev": true, + "requires": { + "is-number": "3.0.0" + } + } + } + }, + "is-expression": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-2.1.0.tgz", + "integrity": "sha1-kb6dR968/vB3l36XIr5tz7RGXvA=", + "requires": { + "acorn": "3.3.0", + "object-assign": "4.1.1" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + } + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=" + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", + "requires": { + "is-extglob": "1.0.0" + } + }, + "is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", + "requires": { + "kind-of": "3.2.2" + } + }, + "is-odd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-1.0.0.tgz", + "integrity": "sha1-O4qTLrAos3dcObsJ6RdnrM22kIg=", + "dev": true, + "requires": { + "is-number": "3.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + } + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "is-posix-bracket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", + "integrity": "sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=" + }, + "is-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=" + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "requires": { + "has": "1.0.1" + } + }, + "is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "requires": { + "is-unc-path": "1.0.0" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "requires": { + "unc-path-regex": "0.1.2" + } + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" + }, + "is-windows": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.1.tgz", + "integrity": "sha1-MQ23D3QtJZoWo2kgK1GvhCMzENk=", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isemail": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-1.2.0.tgz", + "integrity": "sha1-vgPfjMPineTSxd9lASY/H6RZXpo=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "requires": { + "isarray": "1.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + } + } + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "dev": true, + "requires": { + "abbrev": "1.0.9", + "async": "1.5.2", + "escodegen": "1.8.1", + "esprima": "2.7.3", + "glob": "5.0.15", + "handlebars": "4.0.11", + "js-yaml": "3.7.0", + "mkdirp": "0.5.1", + "nopt": "3.0.6", + "once": "1.4.0", + "resolve": "1.1.7", + "supports-color": "3.2.3", + "which": "1.3.0", + "wordwrap": "1.0.0" + }, + "dependencies": { + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "1.0.0" + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + } + } + }, + "joi": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-6.10.1.tgz", + "integrity": "sha1-TVDDGAeRIgAP5fFq8f+OGRe3fgY=", + "requires": { + "hoek": "2.16.3", + "isemail": "1.2.0", + "moment": "2.20.1", + "topo": "1.1.0" + }, + "dependencies": { + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=" + } + } + }, + "js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha1-Fzb939lyTyijaCrcYjCufk6Weds=" + }, + "js-yaml": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.7.0.tgz", + "integrity": "sha1-XJZ93YN6m/3KXy3oQlOr6KHAO4A=", + "requires": { + "argparse": "1.0.9", + "esprima": "2.7.3" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "jscs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jscs/-/jscs-3.0.7.tgz", + "integrity": "sha1-cUG03/W4bjLQ6Z12S4NnZ8MNIBo=", + "dev": true, + "requires": { + "chalk": "1.1.3", + "cli-table": "0.3.1", + "commander": "2.9.0", + "cst": "0.4.10", + "estraverse": "4.2.0", + "exit": "0.1.2", + "glob": "5.0.15", + "htmlparser2": "3.8.3", + "js-yaml": "3.4.6", + "jscs-jsdoc": "2.0.0", + "jscs-preset-wikimedia": "1.0.0", + "jsonlint": "1.6.2", + "lodash": "3.10.1", + "minimatch": "3.0.4", + "natural-compare": "1.2.2", + "pathval": "0.1.1", + "prompt": "0.2.14", + "reserved-words": "0.1.2", + "resolve": "1.5.0", + "strip-bom": "2.0.0", + "strip-json-comments": "1.0.4", + "to-double-quotes": "2.0.0", + "to-single-quotes": "2.0.1", + "vow": "0.4.17", + "vow-fs": "0.3.6", + "xmlbuilder": "3.1.0" + }, + "dependencies": { + "commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", + "dev": true, + "requires": { + "graceful-readlink": "1.0.1" + } + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "js-yaml": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.4.6.tgz", + "integrity": "sha1-a+GyP2JJ9T0pM3D9TRqqY84bTrA=", + "dev": true, + "requires": { + "argparse": "1.0.9", + "esprima": "2.7.3", + "inherit": "2.2.6" + } + }, + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + }, + "pathval": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-0.1.1.tgz", + "integrity": "sha1-CPkRzcqczllCiA2ngXvAtyO2bYI=", + "dev": true + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "0.2.1" + } + }, + "strip-json-comments": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", + "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", + "dev": true + }, + "xmlbuilder": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-3.1.0.tgz", + "integrity": "sha1-LIaIjy1OrehQ+jjKf3Ij9yCVFuE=", + "dev": true, + "requires": { + "lodash": "3.10.1" + } + } + } + }, + "jscs-jsdoc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jscs-jsdoc/-/jscs-jsdoc-2.0.0.tgz", + "integrity": "sha1-9T684CmqMSW9iCkLpQ1k1FEKSHE=", + "dev": true, + "requires": { + "comment-parser": "0.3.2", + "jsdoctypeparser": "1.2.0" + } + }, + "jscs-preset-wikimedia": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jscs-preset-wikimedia/-/jscs-preset-wikimedia-1.0.0.tgz", + "integrity": "sha1-//VjNCA4/C6IJre7cwnDrjQG/H4=", + "dev": true + }, + "jsdoctypeparser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-1.2.0.tgz", + "integrity": "sha1-597cFToRhJ/8UUEUSuhqfvDCU5I=", + "dev": true, + "requires": { + "lodash": "3.10.1" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + } + } + }, + "json-refs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.3.tgz", + "integrity": "sha512-nJUbEsfzqGz4dJCcLh0tyN8yqB4dupdodH07IgyIgLtagIP4lElad10ABrwrcaOB3OKJDCkRUZXpPaRSLLun+A==", + "requires": { + "commander": "2.11.0", + "graphlib": "2.1.5", + "js-yaml": "3.10.0", + "lodash": "4.17.4", + "native-promise-only": "0.8.1", + "path-loader": "1.0.4", + "slash": "1.0.0", + "uri-js": "3.0.2" + }, + "dependencies": { + "esprima": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", + "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==" + }, + "js-yaml": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz", + "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==", + "requires": { + "argparse": "1.0.9", + "esprima": "4.0.0" + } + } + } + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-ref-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-1.4.1.tgz", + "integrity": "sha1-wMLkOL8HlnI7AkUbrovH3Qs3/tA=", + "dev": true, + "requires": { + "call-me-maybe": "1.0.1", + "debug": "2.6.9", + "es6-promise": "3.2.1", + "js-yaml": "3.7.0", + "ono": "2.2.5" + }, + "dependencies": { + "ono": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/ono/-/ono-2.2.5.tgz", + "integrity": "sha1-2vCUiLURdNp6fkJ136sxtDj/oOM=", + "dev": true + } + } + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + }, + "jsonlint": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/jsonlint/-/jsonlint-1.6.2.tgz", + "integrity": "sha1-VzcEUIX1XrRVxosf9OvAG9UOiDA=", + "dev": true, + "requires": { + "JSV": "4.0.2", + "nomnom": "1.8.1" + } + }, + "jsonwebtoken": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz", + "integrity": "sha1-d/UCHeBYtgWheD+hKD6ZgS5kVjg=", + "requires": { + "joi": "6.10.1", + "jws": "3.1.4", + "lodash.once": "4.1.1", + "ms": "2.0.0", + "xtend": "4.0.1" + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=", + "requires": { + "is-promise": "2.1.0", + "promise": "7.3.1" + } + }, + "just-extend": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", + "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==", + "dev": true + }, + "jwa": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.5.tgz", + "integrity": "sha1-oFUs4CIHQs1S4VN3SjKQXDDnVuU=", + "requires": { + "base64url": "2.0.0", + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.9", + "safe-buffer": "5.1.1" + } + }, + "jws": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.4.tgz", + "integrity": "sha1-+ei5M46KhHJ31kRLFGT2GIDgUKI=", + "requires": { + "base64url": "2.0.0", + "jwa": "1.1.5", + "safe-buffer": "5.1.1" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + }, + "kuler": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-0.0.0.tgz", + "integrity": "sha1-tmu0a5NOVQ9Z2BiEjgq7pPf1VTw=", + "requires": { + "colornames": "0.0.2" + } + }, + "lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha1-2qBoIGKCVCwIgojpdcKXwa53tpA=" + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=" + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "dev": true, + "requires": { + "invert-kv": "1.0.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2", + "type-check": "0.3.2" + } + }, + "libbase64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz", + "integrity": "sha1-YjUag5VjrF/1vSbxL2Dpgwu3UeY=" + }, + "libmime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-1.2.0.tgz", + "integrity": "sha1-jYS087Ils3BEECNu9JSQZDa6dCs=", + "requires": { + "iconv-lite": "0.4.19", + "libbase64": "0.1.0", + "libqp": "1.1.0" + } + }, + "libqp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz", + "integrity": "sha1-9ebgatdLeU+1tbZpiL9yjvHe2+g=" + }, + "liftoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.5.0.tgz", + "integrity": "sha1-IAkpG7Mc6oYbvxCnwVooyvdcMew=", + "dev": true, + "requires": { + "extend": "3.0.1", + "findup-sync": "2.0.0", + "fined": "1.1.0", + "flagged-respawn": "1.0.0", + "is-plain-object": "2.0.4", + "object.map": "1.0.1", + "rechoir": "0.6.2", + "resolve": "1.5.0" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "parse-json": "2.2.0", + "pify": "2.3.0", + "pinkie-promise": "2.0.1", + "strip-bom": "2.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "0.2.1" + } + } + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "2.0.0", + "path-exists": "3.0.0" + }, + "dependencies": { + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + } + } + }, + "lodash": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" + }, + "lodash-pickdeep": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/lodash-pickdeep/-/lodash-pickdeep-1.0.2.tgz", + "integrity": "sha1-qL6J0vFpcGMUGphEQPcWEl5UwP8=", + "dev": true, + "requires": { + "lodash": "4.17.4" + } + }, + "lodash._arraypool": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._arraypool/-/lodash._arraypool-2.4.1.tgz", + "integrity": "sha1-6I7suS4ruEyQZWEv2VigcZzUf5Q=" + }, + "lodash._basebind": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._basebind/-/lodash._basebind-2.4.1.tgz", + "integrity": "sha1-6UC5690nwyfgqNqxtVkWxTQelXU=", + "requires": { + "lodash._basecreate": "2.4.1", + "lodash._setbinddata": "2.4.1", + "lodash._slice": "2.4.1", + "lodash.isobject": "2.4.1" + } + }, + "lodash._baseclone": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._baseclone/-/lodash._baseclone-2.4.1.tgz", + "integrity": "sha1-MPgj5X4X43NdODvWK2Czh1Q7QYY=", + "requires": { + "lodash._getarray": "2.4.1", + "lodash._releasearray": "2.4.1", + "lodash._slice": "2.4.1", + "lodash.assign": "2.4.1", + "lodash.foreach": "2.4.1", + "lodash.forown": "2.4.1", + "lodash.isarray": "2.4.1", + "lodash.isobject": "2.4.1" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true + }, + "lodash._basecreate": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-2.4.1.tgz", + "integrity": "sha1-+Ob1tXip405UEXm1a47uv0oofgg=", + "requires": { + "lodash._isnative": "2.4.1", + "lodash.isobject": "2.4.1", + "lodash.noop": "2.4.1" + } + }, + "lodash._basecreatecallback": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._basecreatecallback/-/lodash._basecreatecallback-2.4.1.tgz", + "integrity": "sha1-fQsmdknLKeehOdAQO3wR+uhOSFE=", + "requires": { + "lodash._setbinddata": "2.4.1", + "lodash.bind": "2.4.1", + "lodash.identity": "2.4.1", + "lodash.support": "2.4.1" + } + }, + "lodash._basecreatewrapper": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._basecreatewrapper/-/lodash._basecreatewrapper-2.4.1.tgz", + "integrity": "sha1-TTHy595+E0+/KAN2K4FQsyUZZm8=", + "requires": { + "lodash._basecreate": "2.4.1", + "lodash._setbinddata": "2.4.1", + "lodash._slice": "2.4.1", + "lodash.isobject": "2.4.1" + } + }, + "lodash._basetostring": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz", + "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", + "dev": true + }, + "lodash._basevalues": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz", + "integrity": "sha1-W3dXYoAr3j0yl1A+JjAIIP32Ybc=", + "dev": true + }, + "lodash._createwrapper": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._createwrapper/-/lodash._createwrapper-2.4.1.tgz", + "integrity": "sha1-UdaVeXPaTtVW43KQ2MGhjFPeFgc=", + "requires": { + "lodash._basebind": "2.4.1", + "lodash._basecreatewrapper": "2.4.1", + "lodash._slice": "2.4.1", + "lodash.isfunction": "2.4.1" + } + }, + "lodash._getarray": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._getarray/-/lodash._getarray-2.4.1.tgz", + "integrity": "sha1-+vH3+BD6mFolHCGHQESBCUg55e4=", + "requires": { + "lodash._arraypool": "2.4.1" + } + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true + }, + "lodash._isnative": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._isnative/-/lodash._isnative-2.4.1.tgz", + "integrity": "sha1-PqZAS3hKe+g2x7V1gOHN95sUgyw=" + }, + "lodash._maxpoolsize": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._maxpoolsize/-/lodash._maxpoolsize-2.4.1.tgz", + "integrity": "sha1-nUgvRjuOZq++WcLBTtsRcGAXIzQ=" + }, + "lodash._objecttypes": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._objecttypes/-/lodash._objecttypes-2.4.1.tgz", + "integrity": "sha1-fAt/admKH3ZSn4kLDNsbTf7BHBE=" + }, + "lodash._reescape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reescape/-/lodash._reescape-3.0.0.tgz", + "integrity": "sha1-Kx1vXf4HyKNVdT5fJ/rH8c3hYWo=", + "dev": true + }, + "lodash._reevaluate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz", + "integrity": "sha1-WLx0xAZklTrgsSTYBpltrKQx4u0=", + "dev": true + }, + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", + "dev": true + }, + "lodash._releasearray": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._releasearray/-/lodash._releasearray-2.4.1.tgz", + "integrity": "sha1-phOWMNdtFTawfdyAliiJsIL2pkE=", + "requires": { + "lodash._arraypool": "2.4.1", + "lodash._maxpoolsize": "2.4.1" + } + }, + "lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI=", + "dev": true + }, + "lodash._setbinddata": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._setbinddata/-/lodash._setbinddata-2.4.1.tgz", + "integrity": "sha1-98IAzRuS7yNrOZ7s9zxkjReqlNI=", + "requires": { + "lodash._isnative": "2.4.1", + "lodash.noop": "2.4.1" + } + }, + "lodash._shimkeys": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._shimkeys/-/lodash._shimkeys-2.4.1.tgz", + "integrity": "sha1-bpzJZm/wgfC1psl4uD4kLmlJ0gM=", + "requires": { + "lodash._objecttypes": "2.4.1" + } + }, + "lodash._slice": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash._slice/-/lodash._slice-2.4.1.tgz", + "integrity": "sha1-dFz0GlNZexj2iImFREBe+isG2Q8=" + }, + "lodash.assign": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-2.4.1.tgz", + "integrity": "sha1-hMOVlt1xGBqXsGUpE6fJZ15Jsao=", + "requires": { + "lodash._basecreatecallback": "2.4.1", + "lodash._objecttypes": "2.4.1", + "lodash.keys": "2.4.1" + } + }, + "lodash.bind": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-2.4.1.tgz", + "integrity": "sha1-XRn6AFyMTSNvr0dCx7eh/Kvikmc=", + "requires": { + "lodash._createwrapper": "2.4.1", + "lodash._slice": "2.4.1" + } + }, + "lodash.clonedeep": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-2.4.1.tgz", + "integrity": "sha1-8pIDtAsS/uCkXTYxZIJZvrq8eGg=", + "requires": { + "lodash._baseclone": "2.4.1", + "lodash._basecreatecallback": "2.4.1" + } + }, + "lodash.escape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz", + "integrity": "sha1-mV7g3BjBtIzJLv+ucaEKq1tIdpg=", + "dev": true, + "requires": { + "lodash._root": "3.0.1" + } + }, + "lodash.foreach": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-2.4.1.tgz", + "integrity": "sha1-/j/Do0yGyUyrb5UiVgKCdB4BYwk=", + "requires": { + "lodash._basecreatecallback": "2.4.1", + "lodash.forown": "2.4.1" + } + }, + "lodash.forown": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.forown/-/lodash.forown-2.4.1.tgz", + "integrity": "sha1-eLQer+FAX6lmRZ6kGT/VAtCEUks=", + "requires": { + "lodash._basecreatecallback": "2.4.1", + "lodash._objecttypes": "2.4.1", + "lodash.keys": "2.4.1" + } + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "lodash.identity": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-2.4.1.tgz", + "integrity": "sha1-ZpTP+mX++TH3wxzobHRZfPVg9PE=" + }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true + }, + "lodash.isarray": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-2.4.1.tgz", + "integrity": "sha1-tSoybB9i9tfac6MdVAHfbvRPD6E=", + "requires": { + "lodash._isnative": "2.4.1" + } + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, + "lodash.isfunction": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-2.4.1.tgz", + "integrity": "sha1-LP1XXHPkmKtX4xm3f6Aq3vE6lNE=" + }, + "lodash.isobject": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-2.4.1.tgz", + "integrity": "sha1-Wi5H/mmVPx7mMafrof5k0tBlWPU=", + "requires": { + "lodash._objecttypes": "2.4.1" + } + }, + "lodash.keys": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-2.4.1.tgz", + "integrity": "sha1-SN6kbfj/djKxDXBrissmWR4rNyc=", + "requires": { + "lodash._isnative": "2.4.1", + "lodash._shimkeys": "2.4.1", + "lodash.isobject": "2.4.1" + } + }, + "lodash.noop": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.noop/-/lodash.noop-2.4.1.tgz", + "integrity": "sha1-T7VPgWZS5a4Q6PcvcXo4jHMmU4o=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs=" + }, + "lodash.restparam": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz", + "integrity": "sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=", + "dev": true + }, + "lodash.support": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.4.1.tgz", + "integrity": "sha1-Mg4LZwMWc8KNeiu12eAzGkUkBRU=", + "requires": { + "lodash._isnative": "2.4.1" + } + }, + "lodash.template": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-3.6.2.tgz", + "integrity": "sha1-+M3sxhaaJVvpCYrosMU9N4kx0U8=", + "dev": true, + "requires": { + "lodash._basecopy": "3.0.1", + "lodash._basetostring": "3.0.1", + "lodash._basevalues": "3.0.0", + "lodash._isiterateecall": "3.0.9", + "lodash._reinterpolate": "3.0.0", + "lodash.escape": "3.2.0", + "lodash.keys": "3.1.2", + "lodash.restparam": "3.6.1", + "lodash.templatesettings": "3.1.1" + }, + "dependencies": { + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true + }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, + "requires": { + "lodash._getnative": "3.9.1", + "lodash.isarguments": "3.1.0", + "lodash.isarray": "3.0.4" + } + } + } + }, + "lodash.templatesettings": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz", + "integrity": "sha1-+zB4RHU7Zrnxr6VOJix0UwfbqOU=", + "dev": true, + "requires": { + "lodash._reinterpolate": "3.0.0", + "lodash.escape": "3.2.0" + } + }, + "log-ok": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/log-ok/-/log-ok-0.1.1.tgz", + "integrity": "sha1-vqPdNqzQuKckDXhza1uXxlREozQ=", + "dev": true, + "requires": { + "ansi-green": "0.1.1", + "success-symbol": "0.1.0" + } + }, + "log-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/log-utils/-/log-utils-0.2.1.tgz", + "integrity": "sha1-pMIXoN2aUFFdm5ICBgkas9TgMc8=", + "dev": true, + "requires": { + "ansi-colors": "0.2.0", + "error-symbol": "0.1.0", + "info-symbol": "0.1.0", + "log-ok": "0.1.1", + "success-symbol": "0.1.0", + "time-stamp": "1.1.0", + "warning-symbol": "0.1.0" + } + }, + "logform": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/logform/-/logform-1.2.2.tgz", + "integrity": "sha512-a0TCbuqQWYhVdLie9f0tEP33bMxniAuw2StG1c5KhiTANm+RBRNpbSiGrNGpaiTZeoCiVWVsL+V5F0fpy7Q2Og==", + "requires": { + "colors": "1.1.2", + "fecha": "2.3.2" + }, + "dependencies": { + "colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=" + } + } + }, + "logging-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/logging-helpers/-/logging-helpers-1.0.0.tgz", + "integrity": "sha512-qyIh2goLt1sOgQQrrIWuwkRjUx4NUcEqEGAcYqD8VOnOC6ItwkrVE8/tA4smGpjzyp4Svhc6RodDp9IO5ghpyA==", + "dev": true, + "requires": { + "isobject": "3.0.1", + "log-utils": "0.2.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "lolex": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.3.1.tgz", + "integrity": "sha512-mQuW55GhduF3ppo+ZRUTz1PRjEh1hS5BbqU7d8D0ez2OKxHDod7StPPeAVKisZR5aLkHZjdGWSL42LSONUJsZw==", + "dev": true + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "requires": { + "currently-unhandled": "0.4.1", + "signal-exit": "3.0.2" + } + }, + "lru-cache": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz", + "integrity": "sha512-q4spe4KTfsAS1SUHLO0wz8Qiyf1+vMIAgpRYioFYDMNqKfHQbg+AVDH3i4fvpl71/P1L0dBl+fQi+P37UYf0ew==", + "requires": { + "pseudomap": "1.0.2", + "yallist": "2.1.2" + } + }, + "mailcomposer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-2.1.0.tgz", + "integrity": "sha1-plMYIomWFP7omckiJtgeK5y7GD0=", + "requires": { + "buildmail": "2.0.0", + "libmime": "1.2.0" + } + }, + "make-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.0.tgz", + "integrity": "sha1-V7713IXSOSO6I3ZzJNjo+PPZaUs=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "map-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", + "integrity": "sha1-ih8HiW2CsQkmvTdEokIACfiJdKg=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "1.0.1" + } + }, + "md5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", + "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", + "dev": true, + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "1.1.6" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "mem": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz", + "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=", + "dev": true, + "requires": { + "mimic-fn": "1.1.0" + } + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "requires": { + "camelcase-keys": "2.1.0", + "decamelize": "1.2.0", + "loud-rejection": "1.6.0", + "map-obj": "1.0.1", + "minimist": "1.2.0", + "normalize-package-data": "2.4.0", + "object-assign": "4.1.1", + "read-pkg-up": "1.0.1", + "redent": "1.0.0", + "trim-newlines": "1.0.0" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + } + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", + "requires": { + "arr-diff": "2.0.0", + "array-unique": "0.2.1", + "braces": "1.8.5", + "expand-brackets": "0.1.5", + "extglob": "0.3.2", + "filename-regex": "2.0.1", + "is-extglob": "1.0.0", + "is-glob": "2.0.1", + "kind-of": "3.2.2", + "normalize-path": "2.1.1", + "object.omit": "2.0.1", + "parse-glob": "3.0.4", + "regex-cache": "0.4.4" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "mime-db": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz", + "integrity": "sha1-dMZD2i3Z1qRTmZY0ZbJtXKfXHwE=" + }, + "mime-types": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz", + "integrity": "sha1-Cdejk/A+mVp5+K+Fe3Cp4KsWVXo=", + "requires": { + "mime-db": "1.30.0" + } + }, + "mimic-fn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.1.0.tgz", + "integrity": "sha1-5md4PZLonb00KBi1IwudYqZyrRg=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "1.1.8" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "mixin-deep": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.0.tgz", + "integrity": "sha512-dgaCvoh6i1nosAUBKb0l0pfJ78K8+S9fluyIR2YvAeUD/QuMahnFnF3xYty5eYXMjhGSsB0DsW6A0uAZyetoAg==", + "dev": true, + "requires": { + "for-in": "1.0.2", + "is-extendable": "1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + } + } + }, + "mocha": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.0.0.tgz", + "integrity": "sha512-ukB2dF+u4aeJjc6IGtPNnJXfeby5d4ZqySlIBT0OEyva/DrMjVm5HkQxKnHDLKEfEQBsEnwTg9HHhtPHJdTd8w==", + "dev": true, + "requires": { + "browser-stdout": "1.3.0", + "commander": "2.11.0", + "debug": "3.1.0", + "diff": "3.3.1", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.3", + "he": "1.1.1", + "mkdirp": "0.5.1", + "supports-color": "4.4.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "diff": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.3.1.tgz", + "integrity": "sha512-MKPHZDMB0o6yHyDryUOScqZibp914ksXwAMYMTHj6KO8UeKsRYNJD3oNCKjTqZon+V488P7N/HzXF8t7ZR95ww==", + "dev": true + }, + "growl": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", + "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", + "dev": true + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "supports-color": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", + "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", + "dev": true, + "requires": { + "has-flag": "2.0.0" + } + } + } + }, + "mocha-junit-reporter": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/mocha-junit-reporter/-/mocha-junit-reporter-1.15.0.tgz", + "integrity": "sha1-MJ9LeiD82ibQrWnJt9CAjXcjAsI=", + "dev": true, + "requires": { + "debug": "2.6.9", + "md5": "2.2.1", + "mkdirp": "0.5.1", + "xml": "1.0.1" + } + }, + "moment": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", + "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==" + }, + "mongodb": { + "version": "2.2.34", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.34.tgz", + "integrity": "sha1-o09Zu+thdUrsQy3nLD/iFSakTBo=", + "requires": { + "es6-promise": "3.2.1", + "mongodb-core": "2.1.18", + "readable-stream": "2.2.7" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz", + "integrity": "sha1-BwV6y+JGeyIELTb5jFrVBwVOlbE=", + "requires": { + "buffer-shims": "1.0.0", + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "5.1.1" + } + } + } + }, + "mongodb-core": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.18.tgz", + "integrity": "sha1-TEYTm986HwMt7ZHbSfOO7AFlkFA=", + "requires": { + "bson": "1.0.4", + "require_optional": "1.0.1" + } + }, + "morgan": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.0.tgz", + "integrity": "sha1-0B+mxlhZt2/PMbPLU6OCGjEdgFE=", + "requires": { + "basic-auth": "2.0.0", + "debug": "2.6.9", + "depd": "1.1.1", + "on-finished": "2.3.0", + "on-headers": "1.0.1" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "multer": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.3.0.tgz", + "integrity": "sha1-CSsmcPaEb6SRSWXvyM+Uwg/sbNI=", + "requires": { + "append-field": "0.1.0", + "busboy": "0.2.14", + "concat-stream": "1.6.0", + "mkdirp": "0.5.1", + "object-assign": "3.0.0", + "on-finished": "2.3.0", + "type-is": "1.6.15", + "xtend": "4.0.1" + } + }, + "multipipe": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz", + "integrity": "sha1-Ko8t33Du1WTf8tV/HhoTfZ8FB4s=", + "dev": true, + "requires": { + "duplexer2": "0.0.2" + } + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=" + }, + "nan": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.9.2.tgz", + "integrity": "sha512-ltW65co7f3PQWBDbqVvaU1WtFJUsNW7sWWm4HINhbMQIyVyzIeyZ8toX5TC5eeooE6piZoaEh4cZkueSKG3KYw==", + "optional": true + }, + "nanomatch": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.7.tgz", + "integrity": "sha512-/5ldsnyurvEw7wNpxLFgjVvBLMta43niEYOy0CJ4ntcYSbx6bugRUTQeFb4BR/WanEL1o3aQgHuVLHQaB6tOqg==", + "dev": true, + "requires": { + "arr-diff": "4.0.0", + "array-unique": "0.3.2", + "define-property": "1.0.0", + "extend-shallow": "2.0.1", + "fragment-cache": "0.2.1", + "is-odd": "1.0.0", + "kind-of": "5.1.0", + "object.pick": "1.3.0", + "regex-not": "1.0.0", + "snapdragon": "0.8.1", + "to-regex": "3.0.1" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=" + }, + "natives": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/natives/-/natives-1.1.1.tgz", + "integrity": "sha512-8eRaxn8u/4wN8tGkhlc2cgwwvOLMLUMUn4IYTexMgWd+LyUDfeXVkk2ygQR0hvIHbJQXgHujia3ieUUDwNGkEA==", + "dev": true + }, + "natural-compare": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.2.2.tgz", + "integrity": "sha1-H5bWDjFBysG20FZTzg2urHY69qo=", + "dev": true + }, + "nconf": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/nconf/-/nconf-0.6.9.tgz", + "integrity": "sha1-lXDvFe1vmuays8jV5xtm0xk81mE=", + "requires": { + "async": "0.2.9", + "ini": "1.3.5", + "optimist": "0.6.0" + }, + "dependencies": { + "async": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.9.tgz", + "integrity": "sha1-32MGD789Myhqdqr21Vophtn/hhk=" + }, + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + }, + "optimist": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.0.tgz", + "integrity": "sha1-aUJIJvNAX3nxQub8PZrljU27kgA=", + "requires": { + "minimist": "0.0.10", + "wordwrap": "0.0.3" + } + } + } + }, + "ncp": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-0.4.2.tgz", + "integrity": "sha1-q8xsvT7C7Spyn/bnwfqPAXhKhXQ=" + }, + "needle": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-0.11.0.tgz", + "integrity": "sha1-AqcbAI6vfVWuifuf12hbe4jXvCk=", + "requires": { + "debug": "2.6.9", + "iconv-lite": "0.4.19" + } + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "nise": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.2.0.tgz", + "integrity": "sha512-q9jXh3UNsMV28KeqI43ILz5+c3l+RiNW8mhurEwCKckuHQbL+hTJIKKTiUlCPKlgQ/OukFvSnKB/Jk3+sFbkGA==", + "dev": true, + "requires": { + "formatio": "1.2.0", + "just-extend": "1.1.27", + "lolex": "1.6.0", + "path-to-regexp": "1.7.0", + "text-encoding": "0.6.4" + }, + "dependencies": { + "lolex": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-1.6.0.tgz", + "integrity": "sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=", + "dev": true + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + } + } + }, + "nocache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz", + "integrity": "sha1-ICtIAhoMTL3i34DeFaF0Q8i0OYA=" + }, + "nodemailer": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-1.11.0.tgz", + "integrity": "sha1-TmnLObAwFbHR7wx4qBVBK56Xb3k=", + "requires": { + "libmime": "1.2.0", + "mailcomposer": "2.1.0", + "needle": "0.11.0", + "nodemailer-direct-transport": "1.1.0", + "nodemailer-smtp-transport": "1.1.0" + } + }, + "nodemailer-direct-transport": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nodemailer-direct-transport/-/nodemailer-direct-transport-1.1.0.tgz", + "integrity": "sha1-oveHCO5vFuoFc/yClJ0Tj/Fy9iQ=", + "requires": { + "smtp-connection": "1.3.8" + } + }, + "nodemailer-smtp-transport": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nodemailer-smtp-transport/-/nodemailer-smtp-transport-1.1.0.tgz", + "integrity": "sha1-5sN/MYhaswgOfe089SjErX5pE5g=", + "requires": { + "clone": "1.0.3", + "nodemailer-wellknown": "0.1.10", + "smtp-connection": "1.3.8" + } + }, + "nodemailer-wellknown": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.10.tgz", + "integrity": "sha1-WG24EB2zDLRDjrVGc3pBqtDPE9U=" + }, + "nomnom": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.8.1.tgz", + "integrity": "sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=", + "dev": true, + "requires": { + "chalk": "0.4.0", + "underscore": "1.6.0" + }, + "dependencies": { + "ansi-styles": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.0.0.tgz", + "integrity": "sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=", + "dev": true + }, + "chalk": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz", + "integrity": "sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=", + "dev": true, + "requires": { + "ansi-styles": "1.0.0", + "has-color": "0.1.7", + "strip-ansi": "0.1.1" + } + }, + "strip-ansi": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz", + "integrity": "sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=", + "dev": true + }, + "underscore": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", + "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=", + "dev": true + } + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1.0.9" + } + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "dev": true, + "requires": { + "hosted-git-info": "2.5.0", + "is-builtin-module": "1.0.0", + "semver": "4.3.6", + "validate-npm-package-license": "3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "requires": { + "remove-trailing-separator": "1.1.0" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "2.0.1" + } + }, + "nssocket": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/nssocket/-/nssocket-0.5.3.tgz", + "integrity": "sha1-iDyi7GBfXtZKTVGQsmJUAZKPj40=", + "requires": { + "eventemitter2": "0.4.14", + "lazy": "1.0.11" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I=" + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "0.1.1", + "define-property": "0.2.5", + "kind-of": "3.2.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + } + } + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8=", + "dev": true, + "requires": { + "array-each": "1.0.1", + "array-slice": "1.1.0", + "for-own": "1.0.0", + "isobject": "3.0.1" + }, + "dependencies": { + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "dev": true, + "requires": { + "for-in": "1.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "object.map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", + "integrity": "sha1-z4Plncj8wK1fQlDh94s7gb2AHTc=", + "dev": true, + "requires": { + "for-own": "1.0.0", + "make-iterator": "1.0.0" + }, + "dependencies": { + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "dev": true, + "requires": { + "for-in": "1.0.2" + } + } + } + }, + "object.omit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "integrity": "sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=", + "requires": { + "for-own": "0.1.5", + "is-extendable": "0.1.1" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", + "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1.0.2" + } + }, + "one-time": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz", + "integrity": "sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=" + }, + "ono": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/ono/-/ono-4.0.2.tgz", + "integrity": "sha512-EFXJFoeF+KkZW4lwmcPMKHp2ZU7o6CM+ccX2nPbEJKiJIdyqbIcS1v6pmNgeNJ6x4/vEYn0/8oz66qXSPnnmSQ==", + "dev": true, + "requires": { + "format-util": "1.0.3" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "0.0.10", + "wordwrap": "0.0.3" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "0.1.3", + "fast-levenshtein": "2.0.6", + "levn": "0.3.0", + "prelude-ls": "1.1.2", + "type-check": "0.3.2", + "wordwrap": "1.0.0" + }, + "dependencies": { + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + } + } + }, + "orchestrator": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/orchestrator/-/orchestrator-0.3.8.tgz", + "integrity": "sha1-FOfp4nZPcxX7rBhOUGx6pt+UrX4=", + "dev": true, + "requires": { + "end-of-stream": "0.1.5", + "sequencify": "0.0.7", + "stream-consume": "0.1.0" + } + }, + "ordered-read-streams": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz", + "integrity": "sha1-/VZamvjrRHO6abbtijQ1LLVS8SY=", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-locale": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", + "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==", + "dev": true, + "requires": { + "execa": "0.7.0", + "lcid": "1.0.0", + "mem": "1.1.0" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.2.0.tgz", + "integrity": "sha512-Y/OtIaXtUPr4/YpMv1pCL5L5ed0rumAaAeBSj12F+bSlMdys7i8oQF/GUJmfpTS/QoaRrS/k6pma29haJpsMng==", + "dev": true, + "requires": { + "p-try": "1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "1.2.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE=", + "dev": true, + "requires": { + "is-absolute": "1.0.0", + "map-cache": "0.2.2", + "path-root": "0.1.1" + } + }, + "parse-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=", + "requires": { + "glob-base": "0.3.0", + "is-dotfile": "1.0.3", + "is-extglob": "1.0.0", + "is-glob": "2.0.1" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "1.3.1" + } + }, + "parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=", + "dev": true + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "2.0.1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-loader": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/path-loader/-/path-loader-1.0.4.tgz", + "integrity": "sha512-k/IPo9OWyofATP5gwIehHHQoFShS37zsSIsejKe6fjI+tqK+FnRpiSg4ZfWUpxb0g2PfCreWPqBD4ayjqjqkdQ==", + "requires": { + "native-promise-only": "0.8.1", + "superagent": "3.8.2" + } + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=" + }, + "path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc=", + "dev": true, + "requires": { + "path-root-regex": "0.1.2" + } + }, + "path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "pify": "2.3.0", + "pinkie-promise": "2.0.1" + } + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "2.0.4" + } + }, + "pkginfo": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz", + "integrity": "sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE=" + }, + "platform": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.4.tgz", + "integrity": "sha1-bw+xftqqSPIUQrOpdcBjEw8cPr0=" + }, + "plugin-error": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-0.1.2.tgz", + "integrity": "sha1-O5uzM1zPAPQl4HQ34ZJ2ln2kes4=", + "dev": true, + "requires": { + "ansi-cyan": "0.1.1", + "ansi-red": "0.1.1", + "arr-diff": "1.1.0", + "arr-union": "2.1.0", + "extend-shallow": "1.1.4" + }, + "dependencies": { + "arr-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-1.1.0.tgz", + "integrity": "sha1-aHwydYFjWI/vfeezb6vklesaOZo=", + "dev": true, + "requires": { + "arr-flatten": "1.1.0", + "array-slice": "0.2.3" + } + }, + "arr-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-2.1.0.tgz", + "integrity": "sha1-IPnqtexw9cfSFbEHexw5Fh0pLH0=", + "dev": true + }, + "array-slice": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", + "integrity": "sha1-3Tz7gO15c6dRF82sabC5nshhhvU=", + "dev": true + }, + "extend-shallow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-1.1.4.tgz", + "integrity": "sha1-Gda/lN/AnXa6cR85uHLSH/TdkHE=", + "dev": true, + "requires": { + "kind-of": "1.1.0" + } + }, + "kind-of": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz", + "integrity": "sha1-FAo9LUGjbS78+pN3tiwk+ElaXEQ=", + "dev": true + } + } + }, + "plugin-log": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/plugin-log/-/plugin-log-0.1.0.tgz", + "integrity": "sha1-hgSc9qsQgzOYqTHzaJy67nteEzM=", + "dev": true, + "requires": { + "chalk": "1.1.3", + "dateformat": "1.0.12" + }, + "dependencies": { + "dateformat": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz", + "integrity": "sha1-nxJLZ1lMk3/3BpMuSmQsyo27/uk=", + "dev": true, + "requires": { + "get-stdin": "4.0.1", + "meow": "3.7.0" + } + } + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "preserve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=" + }, + "pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", + "dev": true + }, + "prettyjson": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prettyjson/-/prettyjson-1.2.1.tgz", + "integrity": "sha1-/P+rQdGcq0365eV15kJGYZsS0ok=", + "requires": { + "colors": "1.1.2", + "minimist": "1.2.0" + }, + "dependencies": { + "colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=" + } + } + }, + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + }, + "prom-client": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-10.2.2.tgz", + "integrity": "sha512-d3qCBK41qZx00/WVzWOX4tau9FinCztqaECZiGuMI5vGYD//5VSdKMOZPRQKjVh5RkI4Ex98DI0YPsoFnEo1QQ==", + "requires": { + "tdigest": "0.1.1" + } + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "2.0.6" + } + }, + "prompt": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/prompt/-/prompt-0.2.14.tgz", + "integrity": "sha1-V3VPZPVD/XsIRXB8gY7OYY8F/9w=", + "requires": { + "pkginfo": "0.3.1", + "read": "1.0.7", + "revalidator": "0.1.8", + "utile": "0.2.1", + "winston": "0.8.3" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, + "winston": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-0.8.3.tgz", + "integrity": "sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA=", + "requires": { + "async": "0.2.10", + "colors": "0.6.2", + "cycle": "1.0.3", + "eyes": "0.1.8", + "isstream": "0.1.2", + "pkginfo": "0.3.1", + "stack-trace": "0.0.10" + } + } + } + }, + "proxy-addr": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.2.tgz", + "integrity": "sha1-ZXFQT0e7mI7IGAJT+F3X4UlSvew=", + "requires": { + "forwarded": "0.1.2", + "ipaddr.js": "1.5.2" + } + }, + "ps-tree": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-0.0.3.tgz", + "integrity": "sha1-2/jXUqf+Ivp9WGNWiUmWEOknbdw=", + "requires": { + "event-stream": "0.5.3" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + }, + "pug": { + "version": "2.0.0-rc.4", + "resolved": "https://registry.npmjs.org/pug/-/pug-2.0.0-rc.4.tgz", + "integrity": "sha512-SL7xovj6E2Loq9N0UgV6ynjMLW4urTFY/L/Fprhvz13Xc5vjzkjZjI1QHKq31200+6PSD8PyU6MqrtCTJj6/XA==", + "requires": { + "pug-code-gen": "2.0.0", + "pug-filters": "2.1.5", + "pug-lexer": "3.1.0", + "pug-linker": "3.0.3", + "pug-load": "2.0.9", + "pug-parser": "4.0.0", + "pug-runtime": "2.0.3", + "pug-strip-comments": "1.0.2" + } + }, + "pug-attrs": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-2.0.2.tgz", + "integrity": "sha1-i+KyIlVo/6ddG4Zpgr/59BEa/8s=", + "requires": { + "constantinople": "3.1.0", + "js-stringify": "1.0.2", + "pug-runtime": "2.0.3" + } + }, + "pug-code-gen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-2.0.0.tgz", + "integrity": "sha512-E4oiJT+Jn5tyEIloj8dIJQognbiNNp0i0cAJmYtQTFS0soJ917nlIuFtqVss3IXMEyQKUew3F4gIkBpn18KbVg==", + "requires": { + "constantinople": "3.1.0", + "doctypes": "1.1.0", + "js-stringify": "1.0.2", + "pug-attrs": "2.0.2", + "pug-error": "1.3.2", + "pug-runtime": "2.0.3", + "void-elements": "2.0.1", + "with": "5.1.1" + } + }, + "pug-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-1.3.2.tgz", + "integrity": "sha1-U659nSm7A89WRJOgJhCfVMR/XyY=" + }, + "pug-filters": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-2.1.5.tgz", + "integrity": "sha512-xkw71KtrC4sxleKiq+cUlQzsiLn8pM5+vCgkChW2E6oNOzaqTSIBKIQ5cl4oheuDzvJYCTSYzRaVinMUrV4YLQ==", + "requires": { + "clean-css": "3.4.28", + "constantinople": "3.1.0", + "jstransformer": "1.0.0", + "pug-error": "1.3.2", + "pug-walk": "1.1.5", + "resolve": "1.5.0", + "uglify-js": "2.8.29" + } + }, + "pug-lexer": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-3.1.0.tgz", + "integrity": "sha1-/QhzdtSmdbT1n4/vQiiDQ06VgaI=", + "requires": { + "character-parser": "2.2.0", + "is-expression": "3.0.0", + "pug-error": "1.3.2" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=" + }, + "is-expression": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-3.0.0.tgz", + "integrity": "sha1-Oayqa+f9HzRx3ELHQW5hwkMXrJ8=", + "requires": { + "acorn": "4.0.13", + "object-assign": "4.1.1" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + } + } + }, + "pug-linker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-3.0.3.tgz", + "integrity": "sha512-DCKczglCXOzJ1lr4xUj/lVHYvS+lGmR2+KTCjZjtIpdwaN7lNOoX2SW6KFX5X4ElvW+6ThwB+acSUg08UJFN5A==", + "requires": { + "pug-error": "1.3.2", + "pug-walk": "1.1.5" + } + }, + "pug-load": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-2.0.9.tgz", + "integrity": "sha512-BDdZOCru4mg+1MiZwRQZh25+NTRo/R6/qArrdWIf308rHtWA5N9kpoUskRe4H6FslaQujC+DigH9LqlBA4gf6Q==", + "requires": { + "object-assign": "4.1.1", + "pug-walk": "1.1.5" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + } + } + }, + "pug-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-4.0.0.tgz", + "integrity": "sha512-ocEUFPdLG9awwFj0sqi1uiZLNvfoodCMULZzkRqILryIWc/UUlDlxqrKhKjAIIGPX/1SNsvxy63+ayEGocGhQg==", + "requires": { + "pug-error": "1.3.2", + "token-stream": "0.0.1" + } + }, + "pug-runtime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-2.0.3.tgz", + "integrity": "sha1-mBYmB7D86eJU1CfzOYelrucWi9o=" + }, + "pug-strip-comments": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-1.0.2.tgz", + "integrity": "sha1-0xOvoBvMN0mA4TmeI+vy65vchRM=", + "requires": { + "pug-error": "1.3.2" + } + }, + "pug-walk": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-1.1.5.tgz", + "integrity": "sha512-rJlH1lXerCIAtImXBze3dtKq/ykZMA4rpO9FnPcIgsWcxZLOvd8zltaoeOVFyBSSqCkhhJWbEbTMga8UxWUUSA==" + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=" + }, + "randomatic": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz", + "integrity": "sha512-D5JUjPyJbaJDkuAazpVnSfVkLlpeO3wDlPROTMLGKG1zMFNFRgrciKo1ltz/AzNTkqE0HzDx655QOL51N06how==", + "requires": { + "is-number": "3.0.0", + "kind-of": "4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + } + }, + "read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "requires": { + "mute-stream": "0.0.7" + } + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "1.1.0", + "normalize-package-data": "2.4.0", + "path-type": "1.1.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "1.1.2", + "read-pkg": "1.1.0" + } + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + }, + "readdirp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.1.0.tgz", + "integrity": "sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg=", + "requires": { + "graceful-fs": "4.1.11", + "minimatch": "3.0.4", + "readable-stream": "2.3.3", + "set-immediate-shim": "1.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "5.1.1" + } + } + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "1.5.0" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "requires": { + "indent-string": "2.1.0", + "strip-indent": "1.0.1" + } + }, + "referrer-policy": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.1.0.tgz", + "integrity": "sha1-NXdOtzW/UPtsB46DM0tHI1AgfXk=" + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + }, + "regex-cache": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", + "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==", + "requires": { + "is-equal-shallow": "0.1.3" + } + }, + "regex-not": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.0.tgz", + "integrity": "sha1-Qvg+OXcWIt+CawKvF2Ul1qXxV/k=", + "dev": true, + "requires": { + "extend-shallow": "2.0.1" + } + }, + "relative": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/relative/-/relative-3.0.2.tgz", + "integrity": "sha1-Dc2OxUpdNaPBXhBFA9ZTdbWlNn8=", + "dev": true, + "requires": { + "isobject": "2.1.0" + } + }, + "remarkable": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-1.7.1.tgz", + "integrity": "sha1-qspJchALZqZCpjoQIcpLrBvjv/Y=", + "dev": true, + "requires": { + "argparse": "0.1.16", + "autolinker": "0.15.3" + }, + "dependencies": { + "argparse": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-0.1.16.tgz", + "integrity": "sha1-z9AeD7uj1srtBJ+9dY1A9lGW9Xw=", + "dev": true, + "requires": { + "underscore": "1.7.0", + "underscore.string": "2.4.0" + } + }, + "underscore": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz", + "integrity": "sha1-a7rwh3UA02vjTsqlhODbn+8DUgk=", + "dev": true + } + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=" + }, + "repeat-element": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=" + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "1.0.2" + } + }, + "replace-ext": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz", + "integrity": "sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=", + "dev": true + }, + "request": { + "version": "2.83.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.83.0.tgz", + "integrity": "sha512-lR3gD69osqm6EYLk9wB/G1W/laGWjzH90t1vEa2xuxHD5KUrSzp9pUSfTm+YC5Nxt2T8nMPEvKlhbQayU7bgFw==", + "requires": { + "aws-sign2": "0.7.0", + "aws4": "1.6.0", + "caseless": "0.12.0", + "combined-stream": "1.0.5", + "extend": "3.0.1", + "forever-agent": "0.6.1", + "form-data": "2.3.1", + "har-validator": "5.0.3", + "hawk": "6.0.2", + "http-signature": "1.2.0", + "is-typedarray": "1.0.0", + "isstream": "0.1.2", + "json-stringify-safe": "5.0.1", + "mime-types": "2.1.17", + "oauth-sign": "0.8.2", + "performance-now": "2.1.0", + "qs": "6.5.1", + "safe-buffer": "5.1.1", + "stringstream": "0.0.5", + "tough-cookie": "2.3.3", + "tunnel-agent": "0.6.0", + "uuid": "3.2.1" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "2.0.0", + "semver": "5.4.1" + }, + "dependencies": { + "semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" + } + } + }, + "reserved-words": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved-words/-/reserved-words-0.1.2.tgz", + "integrity": "sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE=", + "dev": true + }, + "resolve": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.5.0.tgz", + "integrity": "sha512-hgoSGrc3pjzAPHNBg+KnFcK2HwlHTs/YrAGUr6qgTVUZmXv1UEXXl0bZNBKMA9fud6lRYFdPGz0xXxycPzmmiw==", + "requires": { + "path-parse": "1.0.5" + } + }, + "resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=", + "dev": true, + "requires": { + "expand-tilde": "2.0.2", + "global-modules": "1.0.0" + } + }, + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "resumer": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz", + "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=", + "requires": { + "through": "2.3.8" + } + }, + "revalidator": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz", + "integrity": "sha1-/s5hv6DBtSoga9axgZgYS91SOjs=" + }, + "rewire": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/rewire/-/rewire-2.5.2.tgz", + "integrity": "sha1-ZCfee3/u+n02QBUH62SlOFvFjcc=", + "dev": true + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "requires": { + "align-text": "0.1.4" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "requires": { + "glob": "7.1.2" + } + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "samsam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", + "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==", + "dev": true + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "selectn": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/selectn/-/selectn-0.9.6.tgz", + "integrity": "sha1-vYc6VW0Y+W2FFfyRUD7G/zmP+aI=" + }, + "semver": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz", + "integrity": "sha1-MAvG4OhjdPe6YQaLWx7NV/xlMto=", + "dev": true + }, + "send": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.1.tgz", + "integrity": "sha512-ElCLJdJIKPk6ux/Hocwhk7NFHpI3pVm/IZOYWqUmoxcgeyM+MpxHHKhb8QmlJDX1pU6WrgaHBkVNm73Sv7uc2A==", + "requires": { + "debug": "2.6.9", + "depd": "1.1.1", + "destroy": "1.0.4", + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "etag": "1.8.1", + "fresh": "0.5.2", + "http-errors": "1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "2.3.0", + "range-parser": "1.2.0", + "statuses": "1.3.1" + }, + "dependencies": { + "statuses": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", + "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=" + } + } + }, + "sequencify": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/sequencify/-/sequencify-0.0.7.tgz", + "integrity": "sha1-kM/xnQLgcCf9dn9erT57ldHnOAw=", + "dev": true + }, + "serve-static": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.1.tgz", + "integrity": "sha512-hSMUZrsPa/I09VYFJwa627JJkNs0NrfL1Uzuup+GqHfToR2KcsXFymXSV90hoyw3M+msjFuQly+YzIH/q0MGlQ==", + "requires": { + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "parseurl": "1.3.2", + "send": "0.16.1" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-getter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/set-getter/-/set-getter-0.1.0.tgz", + "integrity": "sha1-12nBgsnVpR9AkUXy+6guXoboA3Y=", + "dev": true, + "requires": { + "to-object-path": "0.3.0" + } + }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=" + }, + "set-value": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", + "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "dev": true, + "requires": { + "extend-shallow": "2.0.1", + "is-extendable": "0.1.1", + "is-plain-object": "2.0.4", + "split-string": "3.1.0" + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "shush": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shush/-/shush-1.0.0.tgz", + "integrity": "sha1-wnQVqeRY8v7TmyfPjrN8ADeCtDE=", + "requires": { + "caller": "0.0.1", + "strip-json-comments": "0.1.3" + } + }, + "sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "sinon": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-4.1.4.tgz", + "integrity": "sha512-ISJZDPf8RS2z4/LAgy1gIimAvF9zg9C9ClQhLTWYWm4HBZjo1WELXlVfkudjdYeN+GtQ2uVBe52m0npIV0gDow==", + "dev": true, + "requires": { + "diff": "3.2.0", + "formatio": "1.2.0", + "lodash.get": "4.4.2", + "lolex": "2.3.1", + "nise": "1.2.0", + "supports-color": "4.5.0", + "type-detect": "4.0.5" + }, + "dependencies": { + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "supports-color": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "dev": true, + "requires": { + "has-flag": "2.0.0" + } + } + } + }, + "sinon-chai": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-2.14.0.tgz", + "integrity": "sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==", + "dev": true + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=" + }, + "smtp-connection": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-1.3.8.tgz", + "integrity": "sha1-VYMsIWDPswhuHc2H/RwZ+mG39TY=" + }, + "snapdragon": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.1.tgz", + "integrity": "sha1-4StUh/re0+PeoKyR6UAL91tAE3A=", + "dev": true, + "requires": { + "base": "0.11.2", + "debug": "2.6.9", + "define-property": "0.2.5", + "extend-shallow": "2.0.1", + "map-cache": "0.2.2", + "source-map": "0.5.7", + "source-map-resolve": "0.5.1", + "use": "2.0.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "1.0.0", + "isobject": "3.0.1", + "snapdragon-util": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "sntp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.1.0.tgz", + "integrity": "sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg==", + "requires": { + "hoek": "4.2.0" + } + }, + "soap": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/soap/-/soap-0.23.0.tgz", + "integrity": "sha512-mYFu8duYgbaJR7lyJ1Nq2YwdxLC1N8O4xF4es/+GaTlnh2dltZaUxAdJPNHiPudDp8XSYSuHCxB3OrIgJJcmGg==", + "requires": { + "bluebird": "3.5.1", + "concat-stream": "1.6.0", + "debug": "2.6.9", + "ejs": "2.5.7", + "finalhandler": "1.1.0", + "lodash": "3.10.1", + "request": "2.83.0", + "sax": "1.2.4", + "selectn": "0.9.6", + "serve-static": "1.13.1", + "strip-bom": "0.3.1", + "uuid": "3.2.1", + "xml-crypto": "0.8.5" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + } + } + }, + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "requires": { + "amdefine": "1.0.1" + } + }, + "source-map-resolve": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.1.tgz", + "integrity": "sha512-0KW2wvzfxm8NCTb30z0LMNyPqWCdDGE2viwzUaucqJdkTRXtZiSY3I+2A6nVAjmdOy0I4gU8DwnVVGsk9jvP2A==", + "dev": true, + "requires": { + "atob": "2.0.3", + "decode-uri-component": "0.2.0", + "resolve-url": "0.2.1", + "source-map-url": "0.4.0", + "urix": "0.1.0" + } + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "0.5.7" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "spark-md5": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.0.tgz", + "integrity": "sha1-NyIifFTi+vJLHcbZM8wUTm9xv+8=" + }, + "sparkles": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.0.tgz", + "integrity": "sha1-Gsu/tZJDbRC76PeFt8xvgoFQEsM=", + "dev": true + }, + "spdx-correct": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz", + "integrity": "sha1-SzBz2TP/UfORLwOsVRlJikFQ20A=", + "dev": true, + "requires": { + "spdx-license-ids": "1.2.2" + } + }, + "spdx-expression-parse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz", + "integrity": "sha1-m98vIOH0DtRH++JzJmGR/O1RYmw=", + "dev": true + }, + "spdx-license-ids": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz", + "integrity": "sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc=", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "3.0.2" + }, + "dependencies": { + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "1.0.0", + "is-extendable": "1.0.1" + } + }, + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "2.0.4" + } + } + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "sshpk": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", + "integrity": "sha1-US322mKHFEMW3EwY/hzx2UBzm+M=", + "requires": { + "asn1": "0.2.3", + "assert-plus": "1.0.0", + "bcrypt-pbkdf": "1.0.1", + "dashdash": "1.14.1", + "ecc-jsbn": "0.1.1", + "getpass": "0.1.7", + "jsbn": "0.1.1", + "tweetnacl": "0.14.5" + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "0.2.5", + "object-copy": "0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + }, + "stream-consume": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz", + "integrity": "sha1-pB6tGm1ggc63n2WwYZAbbY89HQ8=", + "dev": true + }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, + "string": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/string/-/string-3.3.3.tgz", + "integrity": "sha1-XqIRzZLSKOGEKUmQpsyXs2anfLA=" + }, + "string-replace-async": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/string-replace-async/-/string-replace-async-1.2.1.tgz", + "integrity": "sha1-1SzcfjOBQbvq6jRx3jEhUCjJo6o=", + "dev": true, + "requires": { + "escape-string-regexp": "1.0.5", + "object-assign": "4.1.1" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + } + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0", + "strip-ansi": "4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + } + } + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "stringstream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", + "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-bom": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-0.3.1.tgz", + "integrity": "sha1-noo57/RW/5q8LwWfXyIluw8/fKU=", + "requires": { + "first-chunk-stream": "0.1.0", + "is-utf8": "0.2.1" + } + }, + "strip-bom-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz", + "integrity": "sha1-+H217yYT9paKpUWr/h7HKLaoKco=", + "dev": true, + "requires": { + "first-chunk-stream": "2.0.0", + "strip-bom": "2.0.0" + }, + "dependencies": { + "first-chunk-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz", + "integrity": "sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA=", + "dev": true, + "requires": { + "readable-stream": "2.3.3" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "0.2.1" + } + } + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "requires": { + "get-stdin": "4.0.1" + } + }, + "strip-json-comments": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz", + "integrity": "sha1-Fkxk43Coo8wAyeAbU55WmCPw7lQ=" + }, + "striptags": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.1.1.tgz", + "integrity": "sha1-yMPn/db7S7OjKjt1LltePjgJPr0=", + "dev": true + }, + "success-symbol": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/success-symbol/-/success-symbol-0.1.0.tgz", + "integrity": "sha1-JAIuSG878c3KCUKDt2nEctO3KJc=", + "dev": true + }, + "superagent": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.2.tgz", + "integrity": "sha512-gVH4QfYHcY3P0f/BZzavLreHW3T1v7hG9B+hpMQotGQqurOvhv87GcMCd6LWySmBuf+BDR44TQd0aISjVHLeNQ==", + "requires": { + "component-emitter": "1.2.1", + "cookiejar": "2.1.1", + "debug": "3.1.0", + "extend": "3.0.1", + "form-data": "2.3.1", + "formidable": "1.1.1", + "methods": "1.1.2", + "mime": "1.4.1", + "qs": "6.5.1", + "readable-stream": "2.3.3" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "5.1.1" + } + } + } + }, + "supertest": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.0.0.tgz", + "integrity": "sha1-jUu2j9GDDuBwM7HFpamkAhyWUpY=", + "dev": true, + "requires": { + "methods": "1.1.2", + "superagent": "3.8.2" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "swagger-converter": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/swagger-converter/-/swagger-converter-0.1.7.tgz", + "integrity": "sha1-oJdRnG8e5N1n4wjZtT3cnCslf5c=", + "requires": { + "lodash.clonedeep": "2.4.1" + } + }, + "swagger-methods": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/swagger-methods/-/swagger-methods-1.0.4.tgz", + "integrity": "sha512-xrKFLbrZ6VxRsg+M3uJozJtsEpNI/aPfZsOkoEjXw8vhAqdMIqwTYGj1f4dmUgvJvCdZhV5iArgtqXgs403ltg==", + "dev": true + }, + "swagger-parser": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-3.4.2.tgz", + "integrity": "sha512-himpIkA50AjTvrgtz0PPbzwWoTjj3F3ye/y1PcW/514YEp1A3IhAcJFkkEu7b1zHnSIthnzxC8aTy+XZG0D+iA==", + "dev": true, + "requires": { + "call-me-maybe": "1.0.1", + "debug": "3.1.0", + "es6-promise": "4.2.2", + "json-schema-ref-parser": "1.4.1", + "ono": "4.0.2", + "swagger-methods": "1.0.4", + "swagger-schema-official": "2.0.0-bab6bed", + "z-schema": "3.19.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "es6-promise": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.2.tgz", + "integrity": "sha512-LSas5vsuA6Q4nEdf9wokY5/AJYXry98i0IzXsv49rYsgDGDNDPbqAYR1Pe23iFxygfbGZNR/5VrHXBCh2BhvUQ==", + "dev": true + } + } + }, + "swagger-schema-official": { + "version": "2.0.0-bab6bed", + "resolved": "https://registry.npmjs.org/swagger-schema-official/-/swagger-schema-official-2.0.0-bab6bed.tgz", + "integrity": "sha1-cAcEaNbSl3ylI3suUZyn0Gouo/0=", + "dev": true + }, + "swagger-tools": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/swagger-tools/-/swagger-tools-0.10.3.tgz", + "integrity": "sha512-2eepnAxniKB/oejo4pz4wGnN9hoXfLzs6ChVluDRCVzu98F7HDSRw0C+DwmiarXD5i1rjXK8yLvUuxQxOOKOJg==", + "requires": { + "async": "2.6.0", + "body-parser": "1.18.2", + "commander": "2.11.0", + "debug": "3.1.0", + "js-yaml": "3.7.0", + "json-refs": "3.0.3", + "lodash": "4.17.4", + "multer": "1.3.0", + "parseurl": "1.3.2", + "path-to-regexp": "2.1.0", + "qs": "6.5.1", + "serve-static": "1.13.1", + "spark-md5": "3.0.0", + "string": "3.3.3", + "superagent": "3.8.2", + "swagger-converter": "0.1.7", + "traverse": "0.6.6", + "z-schema": "3.19.0" + }, + "dependencies": { + "async": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", + "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", + "requires": { + "lodash": "4.17.4" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "path-to-regexp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.1.0.tgz", + "integrity": "sha512-dZY7QPCPp5r9cnNuQ955mOv4ZFVDXY/yvqeV7Y1W2PJA3PEFcuow9xKFfJxbBj1pIjOAP+M2B4/7xubmykLrXw==" + } + } + }, + "tape": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tape/-/tape-2.3.3.tgz", + "integrity": "sha1-Lnzgox3wn41oUWZKcYQuDKUFevc=", + "requires": { + "deep-equal": "0.1.2", + "defined": "0.0.0", + "inherits": "2.0.3", + "jsonify": "0.0.0", + "resumer": "0.0.0", + "through": "2.3.8" + }, + "dependencies": { + "deep-equal": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-0.1.2.tgz", + "integrity": "sha1-skbCuApXCkfBG+HZvRBw7IeLh84=" + } + } + }, + "tdigest": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", + "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "requires": { + "bintrees": "1.0.1" + } + }, + "test-console": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/test-console/-/test-console-1.1.0.tgz", + "integrity": "sha512-pntCc+DnxNVZxNIul3NjThWaLvIrp9GNHRMrriyFWFtq10LpbHGsagu7riq7UIZn79f9aXnKI7YgyMvf8dcKsg==", + "dev": true + }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, + "text-hex": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-0.0.0.tgz", + "integrity": "sha1-V4+8haapJjbkLdF7QdAhjM6esrM=" + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=", + "dev": true, + "requires": { + "readable-stream": "1.0.34", + "xtend": "4.0.1" + } + }, + "tildify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz", + "integrity": "sha1-3OwD9V3Km3qj5bBPIYF+tW5jWIo=", + "dev": true, + "requires": { + "os-homedir": "1.0.2" + } + }, + "time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", + "dev": true + }, + "timespan": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/timespan/-/timespan-2.3.0.tgz", + "integrity": "sha1-SQLOBAvRPYRcj1myfp1ZutbzmSk=" + }, + "to-double-quotes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-double-quotes/-/to-double-quotes-2.0.0.tgz", + "integrity": "sha1-qvIx1vqUiUn4GTAburRITYWI5Kc=", + "dev": true + }, + "to-gfm-code-block": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/to-gfm-code-block/-/to-gfm-code-block-0.1.1.tgz", + "integrity": "sha1-JdBFpfrlUxielje1kJANpzLYqoI=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "to-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.1.tgz", + "integrity": "sha1-FTWL7kosg712N3uh3ASdDxiDeq4=", + "dev": true, + "requires": { + "define-property": "0.2.5", + "extend-shallow": "2.0.1", + "regex-not": "1.0.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "3.0.0", + "repeat-string": "1.6.1" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + } + } + }, + "to-single-quotes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/to-single-quotes/-/to-single-quotes-2.0.1.tgz", + "integrity": "sha1-fMKRUfD18sQZRvEZ9ZMv5VQXASU=", + "dev": true + }, + "token-stream": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-0.0.1.tgz", + "integrity": "sha1-zu78cXp2xDFvEm0LnbqlXX598Bo=" + }, + "topo": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/topo/-/topo-1.1.0.tgz", + "integrity": "sha1-6ddRYV0buH3IZdsYL6HKCl71NtU=", + "requires": { + "hoek": "2.16.3" + }, + "dependencies": { + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=" + } + } + }, + "tough-cookie": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.3.tgz", + "integrity": "sha1-C2GKVWW23qkL80JdBNVe3EdadWE=", + "requires": { + "punycode": "1.4.1" + } + }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true + }, + "triple-beam": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.1.0.tgz", + "integrity": "sha1-KsOHyMS9BL0mxh34kaYHn4WS/hA=" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2" + } + }, + "type-detect": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.5.tgz", + "integrity": "sha512-N9IvkQslUGYGC24RkJk1ba99foK6TkwC2FHAEBlQFBP0RxQZS8ZpJuAZcwiY/w9ZJHFQb1aOXBI60OdxhTrwEQ==", + "dev": true + }, + "type-is": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz", + "integrity": "sha1-yrEPtJCeRByChC6v4a1kbIGARBA=", + "requires": { + "media-typer": "0.3.0", + "mime-types": "2.1.17" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "typeof-article": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/typeof-article/-/typeof-article-0.1.1.tgz", + "integrity": "sha1-nwfnM8P7tkb/qeYcCN66zUYOBq8=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "requires": { + "source-map": "0.5.7", + "uglify-to-browserify": "1.0.2", + "yargs": "3.10.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "requires": { + "camelcase": "1.2.1", + "cliui": "2.1.0", + "decamelize": "1.2.0", + "window-size": "0.1.0" + } + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "optional": true + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "1.0.0" + } + }, + "uk-clear-addressing": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uk-clear-addressing/-/uk-clear-addressing-0.1.2.tgz", + "integrity": "sha1-bSc+VlWapOKQpY6YEhym7ftAEv8=" + }, + "unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", + "dev": true + }, + "underscore.string": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.4.0.tgz", + "integrity": "sha1-jN2PusTi0uoefi6Al8QvRCKA+Fs=", + "dev": true + }, + "union-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", + "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "dev": true, + "requires": { + "arr-union": "3.1.0", + "get-value": "2.0.6", + "is-extendable": "0.1.1", + "set-value": "0.4.3" + }, + "dependencies": { + "set-value": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", + "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", + "dev": true, + "requires": { + "extend-shallow": "2.0.1", + "is-extendable": "0.1.1", + "is-plain-object": "2.0.4", + "to-object-path": "0.3.0" + } + } + } + }, + "unique-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-1.0.0.tgz", + "integrity": "sha1-1ZpKdUJ0R9mqbJHnAmP40mpLEEs=", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "0.3.1", + "isobject": "3.0.1" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "2.0.6", + "has-values": "0.1.4", + "isobject": "2.1.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + } + } + }, + "uri-js": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-3.0.2.tgz", + "integrity": "sha1-+QuFhQf4HepNz7s8TD2/orVX+qo=", + "requires": { + "punycode": "2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.0.tgz", + "integrity": "sha1-X4Y+3Im5bbCQdLrXlHvwkFbKTn0=" + } + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "use": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/use/-/use-2.0.2.tgz", + "integrity": "sha1-riig1y+TvyJCKhii43mZMRLeyOg=", + "dev": true, + "requires": { + "define-property": "0.2.5", + "isobject": "3.0.1", + "lazy-cache": "2.0.2" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "0.1.6" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "0.1.6", + "is-data-descriptor": "0.1.4", + "kind-of": "5.1.0" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + }, + "lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=", + "dev": true, + "requires": { + "set-getter": "0.1.0" + } + } + } + }, + "user-home": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz", + "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA=", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utile": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/utile/-/utile-0.2.1.tgz", + "integrity": "sha1-kwyI6ZCY1iIINMNWy9mncFItkNc=", + "requires": { + "async": "0.2.10", + "deep-equal": "1.0.1", + "i": "0.3.6", + "mkdirp": "0.5.1", + "ncp": "0.4.2", + "rimraf": "2.6.2" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + } + } + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", + "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==" + }, + "v8flags": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz", + "integrity": "sha1-qrGh+jDUX4jdMhFIh1rALAtV5bQ=", + "dev": true, + "requires": { + "user-home": "1.1.1" + } + }, + "validate-npm-package-license": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz", + "integrity": "sha1-KAS6vnEq0zeUWaz74kdGqywwP7w=", + "dev": true, + "requires": { + "spdx-correct": "1.0.2", + "spdx-expression-parse": "1.0.4" + } + }, + "validator": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-9.2.0.tgz", + "integrity": "sha512-6Ij4Eo0KM4LkR0d0IegOwluG5453uqT5QyF5SV5Ezvm8/zmkKI/L4eoraafZGlZPC9guLkwKzgypcw8VGWWnGA==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "1.3.0" + } + }, + "vinyl": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.5.3.tgz", + "integrity": "sha1-sEVbOPxeDPMNQyUTLkYZcMIJHN4=", + "dev": true, + "requires": { + "clone": "1.0.3", + "clone-stats": "0.0.1", + "replace-ext": "0.0.1" + } + }, + "vinyl-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-2.0.0.tgz", + "integrity": "sha1-p+v1/779obfRjRQPyweyI++2dRo=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "pify": "2.3.0", + "pinkie-promise": "2.0.1", + "strip-bom": "2.0.0", + "strip-bom-stream": "2.0.0", + "vinyl": "1.2.0" + }, + "dependencies": { + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "0.2.1" + } + }, + "vinyl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.2.0.tgz", + "integrity": "sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=", + "dev": true, + "requires": { + "clone": "1.0.3", + "clone-stats": "0.0.1", + "replace-ext": "0.0.1" + } + } + } + }, + "vinyl-fs": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-0.3.14.tgz", + "integrity": "sha1-mmhRzhysHBzqX+hsCTHWIMLPqeY=", + "dev": true, + "requires": { + "defaults": "1.0.3", + "glob-stream": "3.1.18", + "glob-watcher": "0.0.6", + "graceful-fs": "3.0.11", + "mkdirp": "0.5.1", + "strip-bom": "1.0.0", + "through2": "0.6.5", + "vinyl": "0.4.6" + }, + "dependencies": { + "clone": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz", + "integrity": "sha1-xhJqkK1Pctv1rNskPMN3JP6T/B8=", + "dev": true + }, + "first-chunk-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz", + "integrity": "sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=", + "dev": true + }, + "graceful-fs": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.11.tgz", + "integrity": "sha1-dhPHeKGv6mLyXGMKCG1/Osu92Bg=", + "dev": true, + "requires": { + "natives": "1.1.1" + } + }, + "strip-bom": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz", + "integrity": "sha1-hbiGLzhEtabV7IRnqTWYFzo295Q=", + "dev": true, + "requires": { + "first-chunk-stream": "1.0.0", + "is-utf8": "0.2.1" + } + }, + "vinyl": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.4.6.tgz", + "integrity": "sha1-LzVsh6VQolVGHza76ypbqL94SEc=", + "dev": true, + "requires": { + "clone": "0.2.0", + "clone-stats": "0.0.1" + } + } + } + }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=" + }, + "vow": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/vow/-/vow-0.4.17.tgz", + "integrity": "sha512-A3/9bWFqf6gT0jLR4/+bT+IPTe1mQf+tdsW6+WI5geP9smAp8Kbbu4R6QQCDKZN/8TSCqTlXVQm12QliB4rHfg==", + "dev": true + }, + "vow-fs": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/vow-fs/-/vow-fs-0.3.6.tgz", + "integrity": "sha1-LUxZviLivyYY3fWXq0uqkjvnIA0=", + "dev": true, + "requires": { + "glob": "7.1.2", + "uuid": "2.0.3", + "vow": "0.4.17", + "vow-queue": "0.4.3" + }, + "dependencies": { + "uuid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=", + "dev": true + } + } + }, + "vow-queue": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/vow-queue/-/vow-queue-0.4.3.tgz", + "integrity": "sha512-/poAKDTFL3zYbeQg7cl4BGcfP4sGgXKrHnRFSKj97dteUFu8oyXMwIcdwu8NSx/RmPGIuYx1Bik/y5vU4H/VKw==", + "dev": true, + "requires": { + "vow": "0.4.17" + } + }, + "warning-symbol": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/warning-symbol/-/warning-symbol-0.1.0.tgz", + "integrity": "sha1-uzHdEbeg+dZ6su2V9Fe2WCW7rSE=", + "dev": true + }, + "which": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", + "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==", + "requires": { + "isexe": "2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=" + }, + "winston": { + "version": "3.0.0-rc1", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.0.0-rc1.tgz", + "integrity": "sha512-aNtKirnK2UEe5v56SK0TIEr5ylyYdXyjAaIJXZTk21UlNx7FQclTkVU2T1ZzMtdDM+Rk2b7vrI/e/4n8U84XaQ==", + "requires": { + "async": "1.5.2", + "diagnostics": "1.1.0", + "isstream": "0.1.2", + "logform": "1.2.2", + "one-time": "0.0.4", + "stack-trace": "0.0.10", + "triple-beam": "1.1.0", + "winston-transport": "3.0.1" + } + }, + "winston-mongodb": { + "version": "4.0.0-rc1", + "resolved": "https://registry.npmjs.org/winston-mongodb/-/winston-mongodb-4.0.0-rc1.tgz", + "integrity": "sha512-s+e27+3mHs86RJPpdECAi3SoBX96c5pgooJR/ZUBf03Yb4+Ygb901KAHIe9W2rz67lLSV3kGRsyLUmQ9FnAkWA==", + "requires": { + "@types/winston": "2.3.8", + "mongodb": "2.2.34", + "triple-beam": "1.1.0" + } + }, + "winston-transport": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-3.0.1.tgz", + "integrity": "sha1-gAixXu9WYMT7P6CU1YzL0IUoxY0=" + }, + "with": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/with/-/with-5.1.1.tgz", + "integrity": "sha1-+k2qktrzLE6pTtRTyB8EaGtXXf4=", + "requires": { + "acorn": "3.3.0", + "acorn-globals": "3.1.0" + } + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "1.0.2", + "strip-ansi": "3.0.1" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "x-xss-protection": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/x-xss-protection/-/x-xss-protection-1.0.0.tgz", + "integrity": "sha1-iYr7k4abJGYc+cUvnujbjtB2Tdk=" + }, + "xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", + "dev": true + }, + "xml-crypto": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-0.8.5.tgz", + "integrity": "sha1-K7z7PrM/OoKiGLgiv2craxwg5Tg=", + "requires": { + "xmldom": "0.1.19", + "xpath.js": "1.1.0" + } + }, + "xmldom": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.19.tgz", + "integrity": "sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw=" + }, + "xpath.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz", + "integrity": "sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ==" + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + }, + "yargs": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-10.1.1.tgz", + "integrity": "sha512-7uRL1HZdCbc1QTP+X8mehOPuCYKC/XTaqAPj7gABLfTt6pgLyVRn3QVte4qhtilZouWCvqd1kipgMKl5tKsFiw==", + "dev": true, + "requires": { + "cliui": "4.0.0", + "decamelize": "1.2.0", + "find-up": "2.1.0", + "get-caller-file": "1.0.2", + "os-locale": "2.1.0", + "require-directory": "2.1.1", + "require-main-filename": "1.0.1", + "set-blocking": "2.0.0", + "string-width": "2.1.1", + "which-module": "2.0.0", + "y18n": "3.2.1", + "yargs-parser": "8.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "cliui": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.0.0.tgz", + "integrity": "sha512-nY3W5Gu2racvdDk//ELReY+dHjb9PlIcVDFXP72nVIhq2Gy3LuVXYwJoPVudwQnv1shtohpgkdCKT2YaKY0CKw==", + "dev": true, + "requires": { + "string-width": "2.1.1", + "strip-ansi": "4.0.0", + "wrap-ansi": "2.1.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "2.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + } + } + } + }, + "yargs-parser": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-8.1.0.tgz", + "integrity": "sha512-yP+6QqN8BmrgW2ggLtTbdrOyBNSI7zBa4IykmiV5R1wl1JWNxQvWhMfMdmzIYtKU7oP3OOInY/tl2ov3BDjnJQ==", + "dev": true, + "requires": { + "camelcase": "4.1.0" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + } + } + }, + "year": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/year/-/year-0.2.1.tgz", + "integrity": "sha1-QIOuUgoxiyPshgN/MADLiSvfm7A=", + "dev": true + }, + "z-schema": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-3.19.0.tgz", + "integrity": "sha512-V94f3ODuluBS4kQLLjNhwoMek0dyIXCsvNu/A17dAyJ6sMhT5KkJQwSn07R0naByLIXJWMDk+ruMfI/3G3hS4Q==", + "requires": { + "commander": "2.11.0", + "lodash.get": "4.4.2", + "lodash.isequal": "4.5.0", + "validator": "9.2.0" + } + } + } +} diff --git a/node_server/package.json b/node_server/package.json new file mode 100644 index 0000000..812bde8 --- /dev/null +++ b/node_server/package.json @@ -0,0 +1,89 @@ +{ + "name": "comcarde-node-server", + "version": "7.6.4", + "description": "Node server designed to provide the main Bridge interface point to the Internet.", + "engines": { + "node": "8.x" + }, + "main": "node_server.js", + "dependencies": { + "ajv": "^5.0.0", + "async": "^1.4.2", + "bit-buffer": "0.0.3", + "body-parser": "^1.18.2", + "compression": "^1.6.1", + "connect-mongo": "^1.1.0", + "crc": "^3.4.0", + "data-uri-to-buffer": "^2.0.0", + "debug": "^2.2.0", + "express": "^4.13.3", + "express-rate-limit": "^2.2.0", + "express-session": "^1.12.1", + "forever": "0.15.3", + "gm": "^1.20.0", + "handlebars": "^4.0.8", + "helmet": "^3.8.1", + "http-status-codes": "^1.0.5", + "iconv-lite": "^0.4.19", + "ideal-postcodes": "^1.0.0", + "json-refs": "^3.0.2", + "jsonwebtoken": "^7.4.0", + "lodash": "^4.0.0", + "minimist": "^1.2.0", + "moment": "^2.10.6", + "mongodb": "^2.0.46", + "morgan": "^1.7.0", + "nodemailer": "^1.8.0", + "prom-client": "^10.0.2", + "pug": "^2.0.0-beta6", + "q": "^1.4.1", + "request": "^2.65.0", + "soap": "^0.23.0", + "swagger-tools": "^0.10.3", + "uk-clear-addressing": "^0.1.2", + "uuid": "^3.2.1", + "winston": "^3.0.0-rc1", + "winston-mongodb": "^4.0.0-rc1" + }, + "devDependencies": { + "canduit": "^1.1.1", + "chai": "^4.1.2", + "chai-as-promised": "^7.1.1", + "chai-deep-match": "^1.0.2", + "gulp": "^3.9.0", + "gulp-bump": "^2.2.0", + "gulp-load-plugins": "^1.2.0", + "gulp-plumber": "^1.1.0", + "gulp-print": "^2.0.1", + "gulp-spawn-mocha": "^5.0.0", + "gulp-task-listing": "^1.0.1", + "gulp-util": "^3.0.8", + "gulp-watch": "^4.3.5", + "handlebars-helpers": "^0.9.6", + "istanbul": "^0.4.5", + "jscs": "^3.0.7", + "mocha": "^5.0.0", + "mocha-junit-reporter": "^1.15.0", + "rewire": "^2.5.2", + "sinon": "^4.0.2", + "sinon-chai": "^2.14.0", + "string-replace-async": "^1.2.1", + "supertest": "^3.0.0", + "swagger-parser": "^3.3.0", + "test-console": "^1.1.0", + "winston-transport": "^3.0.1", + "yargs": "^10.0.3" + }, + "scripts": { + "test": "gulp test", + "coverage": "./node_modules/.bin/istanbul cover -x \"**/*.spec.js\" ./node_modules/mocha/bin/_mocha \"./{,!(node_modules)/**/}*.spec.js\"", + "lint": "../node_modules/.bin/eslint ./dev_api/**/*.js" + }, + "repository": { + "type": "git", + "url": "ssh://git@10.0.10.242/diffusion/BS/bridge-server.git" + }, + "author": "Comcarde Ltd", + "license": "UNLICENSED", + "private": true +} diff --git a/node_server/portal-router.js b/node_server/portal-router.js new file mode 100644 index 0000000..0060df5 --- /dev/null +++ b/node_server/portal-router.js @@ -0,0 +1,124 @@ +/* + * This file defines the routes for the web portal running on the api server + * It uses express router so it can be placed under any path, and will then + * assume everything else is under the root of that path. + * e.g. to place under /portal/ you would do: + * + * var appHttps = [main app setup] + * var portalRouterFactory = require(); + * var portalRouter = portalRouterFactory(); + * appHttps.use('/portal/', portalRouter); + */ +const path = require('path'); +const express = require('express'); +const bodyParser = require('body-parser'); +const compression = require('compression'); +const RateLimit = require('express-rate-limit'); +const helmet = require('helmet'); + +const config = require(global.configFile); + +/** + * Content security policy options + */ +const cspOptions = { + directives: { + defaultSrc: ['\'self\''], + + /** + * Image Sources: + * - self: image served from the exact domain and scheme + * - *.base.maps.api.here.com: map tile server for normal tiles + * - *.aerial.maps.api.here.com: map tile server for satellite tiles + * - data: images from data uris (used for profile pics etc.) + */ + imgSrc: [ + '\'self\'', + 'https://*.base.maps.api.here.com', + 'https://*.aerial.maps.api.here.com', + 'data:' + ], + + /** + * Frame Source: + * - www.comcarde.com for the framed updated terms and conditions + */ + frameSrc: ['https://www.comcarde.com'], + + /** + * Connect Source + * - 'self': API requests, etc. + * - *.base.maps.api.here.com: copyright attribution info for normal tiles + * - *.aerial.maps.api.here.com: copyright attribution info for satellite tiles + */ + connectSrc: [ + '\'self\'', + 'https://*.base.maps.api.here.com', + 'https://*.aerial.maps.api.here.com' + ], + + reportUri: '/api/v0/csp-report' + }, + reportOnly: false +}; + +module.exports = (function() { + return function portalRouterFactory(filePath) { + const router = express.Router(); + + /* Body Parsers, including for application/csp-report */ + router.use(bodyParser.json({type: 'application/json'})); + router.use(bodyParser.json({type: 'application/csp-report'})); + + /* + * Rate Limiting + */ + const limiter = new RateLimit(config.rateLimits.portalStatic); + router.use(limiter); + + /* + * Content Security Policy (CSP) headers + */ + router.use(helmet.contentSecurityPolicy(cspOptions)); + + /* + * Enable compression + */ + router.use(compression()); + + /* + * Serve basic available files + */ + router.use(express.static(filePath)); + + /* + * JSON translation files + */ + router.use( + express.static(path.resolve(filePath, './app/*/i18n/*.json')) + ); + + /* + * If the request looks like a request for a template, and the template + * doesn't exists, return a 404. + */ + router.use('/app/*', (req, res) => { + res.status(404).end(); + }); + + /* + * Any other requests are guessed to be from the manipulated URLs for the + * pages in the web app. So we serve index.html in response to them, which + * will kickstart everything + */ + router.use( + '/*', + express.static(path.resolve(filePath, './index.html')) + ); + + /* + * Return the configured router + */ + return router; + }; +})(); diff --git a/node_server/prometheus-router.js b/node_server/prometheus-router.js new file mode 100644 index 0000000..9a5bf1a --- /dev/null +++ b/node_server/prometheus-router.js @@ -0,0 +1,52 @@ +/* + * This file defines the routes for the prometheus monitoring running on the api server + * It uses express router so it can be placed under any path, and will then + * assume everything else is under the root of that path. + * e.g. to place under /metrics you would do: + * + * var appHttps = [main app setup] + * var promRouterFactory = require(); + * var promRouter = prometheusRouterFactory(); + * appHttps.use('/metrics', promRouter); + */ +'use strict'; + +var path = require('path'); +var express = require('express'); +var morgan = require('morgan'); +var promClient = require('prom-client'); + +const AUTHORIZATION_TOKEN = 'f7632bff-7ef4-4ecb-aba1-0ab678712556'; + +module.exports = (function() { + return function prometheusRouterFactory() { + var router = express.Router(); + + // + // Logging middleware + // + router.use(morgan('combined')); + + /* + * If the request looks like a request for metrics then return them. + */ + router.use('/', function(req, res, next) { + /** + * Check the authorization header value is correct + */ + if (req.headers.authorization !== 'Bearer ' + AUTHORIZATION_TOKEN) { + res.status(401).end(); + } else { + /** + * All ok, so return the metrics + */ + res.end(promClient.register.metrics()); + } + }); + + /* + * Return the configured router + */ + return router; + }; +})(); diff --git a/node_server/pug/adminNotifier/credits_low.pug b/node_server/pug/adminNotifier/credits_low.pug new file mode 100644 index 0000000..4418624 --- /dev/null +++ b/node_server/pug/adminNotifier/credits_low.pug @@ -0,0 +1,7 @@ +doctype html +html(lang='lang:en-gb') + body + h1 #{Service} credits are low! + p Please buy more! + p Credits Remaining: #{CreditsRemaining} + p Low credit reporting starts at: #{CreditsLimit} \ No newline at end of file diff --git a/node_server/pug/adminNotifier/identity_check.pug b/node_server/pug/adminNotifier/identity_check.pug new file mode 100644 index 0000000..18fda0f --- /dev/null +++ b/node_server/pug/adminNotifier/identity_check.pug @@ -0,0 +1,6 @@ +doctype html +html(lang='lang:en-gb') + body + h1 Manual Identity Check Needed + p Client #{ClientID} needs further investigation. + p See #[a(href=ProfileURL) tracesmart result] \ No newline at end of file diff --git a/node_server/pug/console.css b/node_server/pug/console.css new file mode 100644 index 0000000..933661a --- /dev/null +++ b/node_server/pug/console.css @@ -0,0 +1,153 @@ + + \ No newline at end of file diff --git a/node_server/pug/errors/54_email_not_found_main.pug b/node_server/pug/errors/54_email_not_found_main.pug new file mode 100644 index 0000000..429f58e --- /dev/null +++ b/node_server/pug/errors/54_email_not_found_main.pug @@ -0,0 +1,5 @@ +#main + h2 Delete Registration Error + p Unfortunately we were not able to remove the e-mail address #{ClientName} as it cannot be found in the database (Error Code #{errornumber}). It should now be possible to sign up using this e-mail address, so please try again from your mobile device. + p If you think you have received this message in error, please contact us at #{""} + a(href="mailto:admin@comcarde.com") admin@comcarde.com \ No newline at end of file diff --git a/node_server/pug/errors/56_mobile_number_not_found_main.pug b/node_server/pug/errors/56_mobile_number_not_found_main.pug new file mode 100644 index 0000000..fe6af09 --- /dev/null +++ b/node_server/pug/errors/56_mobile_number_not_found_main.pug @@ -0,0 +1,5 @@ +#main + h2 Delete Registration Error + p Unfortunately we were not able to remove the mobile number #{DeviceNumber} as it cannot be found in the database (Error Code #{errornumber}). It should now be possible to sign up using this number, so please try again from your mobile device. + p If you think you have received this message in error, please contact us at #{""} + a(href="mailto:admin@comcarde.com") admin@comcarde.com \ No newline at end of file diff --git a/node_server/pug/errors/57_association_error_main.pug b/node_server/pug/errors/57_association_error_main.pug new file mode 100644 index 0000000..896d676 --- /dev/null +++ b/node_server/pug/errors/57_association_error_main.pug @@ -0,0 +1,5 @@ +#main + h2 Delete Registration Error + p Unfortunately the mobile number #{DeviceNumber} is not associated with the e-mail #{ClientName} (Error Code #{errornumber}). + p This error is triggered when there has been a suspected security problem. Please contact us at #{""} + a(href="mailto:admin@comcarde.com") admin@comcarde.com \ No newline at end of file diff --git a/node_server/pug/errors/58_fully_registered_main.pug b/node_server/pug/errors/58_fully_registered_main.pug new file mode 100644 index 0000000..b75b6b4 --- /dev/null +++ b/node_server/pug/errors/58_fully_registered_main.pug @@ -0,0 +1,5 @@ +#main + h2 Delete Registration Error + p This e-mail and mobile number combination has already been registered with Bridge (Error Code #{errornumber}). Please log in to manage the account using the link to the left. + p If you think you have received this message in error, please contact us at #{""} + a(href="mailto:admin@comcarde.com") admin@comcarde.com \ No newline at end of file diff --git a/node_server/pug/errors/undef_database_offline_main.pug b/node_server/pug/errors/undef_database_offline_main.pug new file mode 100644 index 0000000..0a718c1 --- /dev/null +++ b/node_server/pug/errors/undef_database_offline_main.pug @@ -0,0 +1,5 @@ +#main + h2 Database Offline + p Unfortunately we were not able to complete the request as the database is offline (Error Code #{errornumber}). This is only a temporary fault so please try again later. + p If you think you have received this message in error, please contact us at #{""} + a(href="mailto:admin@comcarde.com") admin@comcarde.com \ No newline at end of file diff --git a/node_server/pug/footer/left.pug b/node_server/pug/footer/left.pug new file mode 100644 index 0000000..64eba40 --- /dev/null +++ b/node_server/pug/footer/left.pug @@ -0,0 +1,3 @@ +#footerleft + #pl + p 2016 Comcarde Ltd. \ No newline at end of file diff --git a/node_server/pug/footer/right.pug b/node_server/pug/footer/right.pug new file mode 100644 index 0000000..89973a2 --- /dev/null +++ b/node_server/pug/footer/right.pug @@ -0,0 +1,6 @@ +#footerright + #pr + if (UserName) && (ipInfo) + p Logged in as #{UserName} (IP: #{ipInfo}) + if (!UserName) && (ipInfo) + p (IP: #{ipInfo}) \ No newline at end of file diff --git a/node_server/pug/header/logo.pug b/node_server/pug/header/logo.pug new file mode 100644 index 0000000..2343812 --- /dev/null +++ b/node_server/pug/header/logo.pug @@ -0,0 +1,2 @@ +#logo + IMG(src="/bridgelogo.png") diff --git a/node_server/pug/header/title.pug b/node_server/pug/header/title.pug new file mode 100644 index 0000000..a666ddc --- /dev/null +++ b/node_server/pug/header/title.pug @@ -0,0 +1,3 @@ +#title + #pc + H1 #{title} \ No newline at end of file diff --git a/node_server/pug/main/10005_reg_deleted.pug b/node_server/pug/main/10005_reg_deleted.pug new file mode 100644 index 0000000..a1a0732 --- /dev/null +++ b/node_server/pug/main/10005_reg_deleted.pug @@ -0,0 +1,5 @@ +#main + h2 Registration Deleted + p The registration using the e-mail address #{ClientName} and mobile number #{DeviceNumber} has been successfully deleted and device/client details removed (Code 10005). Note that cards remain linked to the Client e-mail address and will be available if the account is re-registered. + p If you have any questions or concerns, please contact us at #{""} + a(href="mailto:admin@comcarde.com") admin@comcarde.com \ No newline at end of file diff --git a/node_server/pug/navigation/nav1.pug b/node_server/pug/navigation/nav1.pug new file mode 100644 index 0000000..46f51ff --- /dev/null +++ b/node_server/pug/navigation/nav1.pug @@ -0,0 +1,6 @@ +#nav + p + a(href="https://dev.bridgepay.uk/portal/login") User Login

+ a(href="https://dev.bridgepay.uk") Web Application

+ a(href="http://www.comcarde.com") Comcarde Website
+ a(href="http://www.bridge.co.com") Bridge Website
\ No newline at end of file diff --git a/node_server/pug/templates/10005_reg_deleted.pug b/node_server/pug/templates/10005_reg_deleted.pug new file mode 100644 index 0000000..ead9f8d --- /dev/null +++ b/node_server/pug/templates/10005_reg_deleted.pug @@ -0,0 +1,19 @@ +doctype html +html(lang='lang:en-gb') + head + title #{title} + include ../console.css + body + #wrap + #container1 + #container2 + include ../header/logo.pug + include ../header/title.pug + #container3 + #container4 + include ../navigation/nav1.pug + include ../main/10005_reg_deleted.pug + #container5 + #container6 + include ../footer/left.pug + include ../footer/right.pug \ No newline at end of file diff --git a/node_server/pug/templates/54_email_not_found.pug b/node_server/pug/templates/54_email_not_found.pug new file mode 100644 index 0000000..53264d5 --- /dev/null +++ b/node_server/pug/templates/54_email_not_found.pug @@ -0,0 +1,19 @@ +doctype html +html(lang='lang:en-gb') + head + title #{title} + include ../console.css + body + #wrap + #container1 + #container2 + include ../header/logo.pug + include ../header/title.pug + #container3 + #container4 + include ../navigation/nav1.pug + include ../errors/54_email_not_found_main.pug + #container5 + #container6 + include ../footer/left.pug + include ../footer/right.pug \ No newline at end of file diff --git a/node_server/pug/templates/56_mobile_number_not_found.pug b/node_server/pug/templates/56_mobile_number_not_found.pug new file mode 100644 index 0000000..5f35ebc --- /dev/null +++ b/node_server/pug/templates/56_mobile_number_not_found.pug @@ -0,0 +1,19 @@ +doctype html +html(lang='lang:en-gb') + head + title #{title} + include ../console.css + body + #wrap + #container1 + #container2 + include ../header/logo.pug + include ../header/title.pug + #container3 + #container4 + include ../navigation/nav1.pug + include ../errors/56_mobile_number_not_found_main.pug + #container5 + #container6 + include ../footer/left.pug + include ../footer/right.pug \ No newline at end of file diff --git a/node_server/pug/templates/57_association_error.pug b/node_server/pug/templates/57_association_error.pug new file mode 100644 index 0000000..15d2e4a --- /dev/null +++ b/node_server/pug/templates/57_association_error.pug @@ -0,0 +1,19 @@ +doctype html +html(lang='lang:en-gb') + head + title #{title} + include ../console.css + body + #wrap + #container1 + #container2 + include ../header/logo.pug + include ../header/title.pug + #container3 + #container4 + include ../navigation/nav1.pug + include ../errors/57_association_error_main.pug + #container5 + #container6 + include ../footer/left.pug + include ../footer/right.pug \ No newline at end of file diff --git a/node_server/pug/templates/58_fully_registered.pug b/node_server/pug/templates/58_fully_registered.pug new file mode 100644 index 0000000..78038af --- /dev/null +++ b/node_server/pug/templates/58_fully_registered.pug @@ -0,0 +1,19 @@ +doctype html +html(lang='lang:en-gb') + head + title #{title} + include ../console.css + body + #wrap + #container1 + #container2 + include ../header/logo.pug + include ../header/title.pug + #container3 + #container4 + include ../navigation/nav1.pug + include ../errors/58_fully_registered_main.pug + #container5 + #container6 + include ../footer/left.pug + include ../footer/right.pug \ No newline at end of file diff --git a/node_server/pug/templates/undef_database_offline.pug b/node_server/pug/templates/undef_database_offline.pug new file mode 100644 index 0000000..ef129e9 --- /dev/null +++ b/node_server/pug/templates/undef_database_offline.pug @@ -0,0 +1,19 @@ +doctype html +html(lang='lang:en-gb') + head + title #{title} + include ../console.css + body + #wrap + #container1 + #container2 + include ../header/logo.pug + include ../header/title.pug + #container3 + #container4 + include ../navigation/nav1.pug + include ../errors/undef_database_offline_main.pug + #container5 + #container6 + include ../footer/left.pug + include ../footer/right.pug \ No newline at end of file diff --git a/node_server/schemas/AcceptEULA.json b/node_server/schemas/AcceptEULA.json new file mode 100644 index 0000000..275799d --- /dev/null +++ b/node_server/schemas/AcceptEULA.json @@ -0,0 +1,20 @@ +{ + "$id": "AcceptEULA", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "AcceptEULA Command", + "description": "Schema for AcceptEULA command", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/AccountCommands.spec.js b/node_server/schemas/AccountCommands.spec.js new file mode 100644 index 0000000..d7d501a --- /dev/null +++ b/node_server/schemas/AccountCommands.spec.js @@ -0,0 +1,1059 @@ +/** + * @fileOverview Unit tests for the schemas for the Account commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/} + */ +var testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_SHA256 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +const VALID_UUID = '0123456789abcdef01234567'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + AddAddress: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressDescription': 'This is an address', + 'BuildingNameFlat': '1F1', + 'Address1': '123 Something Building', + 'Address2': '12 Some Street', + 'Town': 'The Town', + 'County': 'The County', + 'PostCode': 'AA1 1AA', + 'Country': 'United Kingdom', + 'PhoneNumber': '+447734180564' + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressDescription': 'This is an address', + 'BuildingNameFlat': '1F1', + 'Address1': '123 Something Building', + 'Address2': '12 Some Street', + 'Town': 'The Town', + 'County': 'The County', + 'PostCode': 'AA1 1AA', + 'Country': 'United Kingdom', + 'PhoneNumber': '+447734180564', + extraParam: 0 + } + }, + { + name: 'with only required params', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressDescription': 'This is an address', + 'Address1': '123 Something Building', + 'Town': 'The Town', + 'PostCode': 'AA1 1AA', + 'Country': 'United Kingdom' + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 7 + }, + data: {} + }, + { + name: 'invalid country', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressDescription': 'This is an address', + 'Address1': '123 Something Building', + 'Town': 'The Town', + 'PostCode': 'AA1 1AA', + 'Country': 'France' + } + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.AddressDescription', + keyword: 'ensureTrim' + }, + { + dataPath: '.AddressDescription', + keyword: 'pattern' + }, + { + dataPath: '.BuildingNameFlat', + keyword: 'ensureTrim' + }, + { + dataPath: '.BuildingNameFlat', + keyword: 'pattern' + }, + { + dataPath: '.Address1', + keyword: 'ensureTrim' + }, + { + dataPath: '.Address1', + keyword: 'pattern' + }, + { + dataPath: '.Address2', + keyword: 'ensureTrim' + }, + { + dataPath: '.Address2', + keyword: 'pattern' + }, + { + dataPath: '.Town', + keyword: 'ensureTrim' + }, + { + dataPath: '.Town', + keyword: 'pattern' + }, + { + dataPath: '.County', + keyword: 'minLength' + }, + { + dataPath: '.County', + keyword: 'pattern' + }, + { + dataPath: '.PostCode', + keyword: 'ensureTrim' + }, + { + dataPath: '.PostCode', + keyword: 'pattern' + }, + { + dataPath: '.Country', + keyword: 'const' + }, + { + dataPath: '.PhoneNumber', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressDescription': ' This is an address~', + 'BuildingNameFlat': '1F1~ ', + 'Address1': '123 Something Building~ ', + 'Address2': ' 12 Pipe(|) Street ', + 'Town': ' ~The Town~ ', + 'County': 'C~', + 'PostCode': 'AA1~1AA ', + 'Country': 'USA', + 'PhoneNumber': '+17734180564' + } + } + ], + AddCard: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientAccountName': 'This is my accounts name', + 'UserImage': 'Selfie', + 'NameOnAccount': 'Mr My Name', + 'CardPAN': '01234567890123456', + 'CardValidFrom': '12-99', + 'CardExpiry': '12-25', + 'CVV': '123', + 'IssueNumber': '1', + 'BillingAddress': VALID_UUID, + 'ClientKey': VALID_SHA256 + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientAccountName': 'This is my accounts name', + 'UserImage': 'Selfie', + 'NameOnAccount': 'Mr My Name', + 'CardPAN': '01234567890123456', + 'CardValidFrom': '09-99', + 'CardExpiry': '09-25', + 'CVV': '123', + 'IssueNumber': '1', + 'BillingAddress': VALID_UUID, + 'ClientKey': VALID_SHA256, + extraParam: 'test' + } + }, + { + name: 'with only required params', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientAccountName': 'This is my accounts name', + 'UserImage': 'Selfie', + 'NameOnAccount': 'Mr My Name', + 'CardPAN': '01234567890123456', + 'CardExpiry': '05-25', + 'CVV': '123', + 'BillingAddress': VALID_UUID, + 'ClientKey': VALID_SHA256 + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 10 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientAccountName', + keyword: 'ensureTrim' + }, + { + dataPath: '.ClientAccountName', + keyword: 'pattern' + }, + { + dataPath: '.UserImage', + keyword: 'enum' + }, + { + dataPath: '.NameOnAccount', + keyword: 'ensureTrim' + }, + { + dataPath: '.NameOnAccount', + keyword: 'pattern' + }, + { + dataPath: '.CardPAN', + keyword: 'pattern' + }, + { + dataPath: '.CardValidFrom', + keyword: 'pattern' + }, + { + dataPath: '.CardExpiry', + keyword: 'pattern' + }, + { + dataPath: '.CVV', + keyword: 'minLength' + }, + { + dataPath: '.IssueNumber', + keyword: 'minLength' + }, + { + dataPath: '.BillingAddress', + keyword: 'minLength' + }, + { + dataPath: '.BillingAddress', + keyword: 'pattern' // Both are wrong + }, + { + dataPath: '.ClientKey', + keyword: 'minLength' + }, + { + dataPath: '.ClientKey', + keyword: 'pattern' // Both are wrong + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientAccountName': ' This is my accounts name~', + 'UserImage': 'something.jpg', + 'NameOnAccount': 'Mr My Name | ', + 'CardPAN': '0123 4567 8901 2345', + 'CardValidFrom': '22-99', + 'CardExpiry': '01-12-25', + 'CVV': '1', + 'IssueNumber': '', + 'BillingAddress': '123 Walker St', + 'ClientKey': 'shouldBeSHA256NotUUID' + } + }, + { + name: 'invalid month (00): T2003', + valid: false, + expect: { + errors: [ + { + dataPath: '.CardValidFrom', + keyword: 'pattern' + }, + { + dataPath: '.CardExpiry', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientAccountName': 'This is my accounts name', + 'UserImage': 'Selfie', + 'NameOnAccount': 'Mr My Name', + 'CardPAN': '01234567890123456', + 'CardValidFrom': '00-00', + 'CardExpiry': '00-25', + 'CVV': '123', + 'BillingAddress': VALID_UUID, + 'ClientKey': VALID_SHA256 + } + }, + { + name: 'invalid month (13): T2003', + valid: false, + expect: { + errors: [ + { + dataPath: '.CardValidFrom', + keyword: 'pattern' + }, + { + dataPath: '.CardExpiry', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientAccountName': 'This is my accounts name', + 'UserImage': 'Selfie', + 'NameOnAccount': 'Mr My Name', + 'CardPAN': '01234567890123456', + 'CardValidFrom': '13-00', + 'CardExpiry': '13-25', + 'CVV': '123', + 'BillingAddress': VALID_UUID, + 'ClientKey': VALID_SHA256 + } + } + ], + ChangePIN: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'DeviceAuthorisation': VALID_SHA256, + 'NewAuthorisation': VALID_SHA256 + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'DeviceAuthorisation': VALID_SHA256, + 'NewAuthorisation': VALID_SHA256, + extra: 1 + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 4 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceAuthorisation', + keyword: 'minLength' + }, + { + dataPath: '.DeviceAuthorisation', + keyword: 'pattern' + }, + { + dataPath: '.NewAuthorisation', + keyword: 'minLength' + }, + { + dataPath: '.NewAuthorisation', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'DeviceAuthorisation': 'Not a SHA256', + 'NewAuthorisation': 'abcdefg!' + } + } + ], + ChangePassword: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'Password': VALID_SHA256, + 'NewPassword': VALID_SHA256 + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'Password': VALID_SHA256, + 'NewPassword': VALID_SHA256, + extraParam: true + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 4 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.Password', + keyword: 'minLength' + }, + { + dataPath: '.Password', + keyword: 'pattern' + }, + { + dataPath: '.NewPassword', + keyword: 'minLength' + }, + { + dataPath: '.NewPassword', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'Password': 'Not a SHA256', + 'NewPassword': 'abcdefg!' + } + } + ], + DeleteAccount: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': VALID_UUID + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': VALID_UUID, + extra: 'Yes' + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.AccountID', + keyword: 'minLength' + }, + { + dataPath: '.AccountID', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': 'Not a UUID' + } + } + ], + DeleteAddress: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressID': VALID_UUID + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressID': VALID_UUID, + extra: true + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.AddressID', + keyword: 'minLength' + }, + { + dataPath: '.AddressID', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AddressID': 'Not a UUID' + } + } + ], + GetTransactionDetail: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TransactionID': VALID_UUID + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TransactionID': VALID_UUID, + extra: 1 + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.TransactionID', + keyword: 'minLength' + }, + { + dataPath: '.TransactionID', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TransactionID': 'Not a UUID' + } + } + ], + GetTransactionHistory: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TimeStamp': '2015-03-02T17:52:40.000Z', + 'AccountID': VALID_UUID, + 'Skip': 0, + 'Number': 1 + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TimeStamp': '2015-03-02T17:52:40.000Z', + 'AccountID': VALID_UUID, + 'Skip': 0, + 'Number': 1, + extra: true + } + }, + { + name: 'required only', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'Skip': 0, + 'Number': 1 + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 4 + }, + data: {} + }, + { + name: 'floating point skip and number', + valid: false, + expect: { + errors: [ + { + dataPath: '.Skip', + keyword: 'maxDecimalPlaces' + }, + { + dataPath: '.Number', + keyword: 'maxDecimalPlaces' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'Skip': 0.1, + 'Number': 1.5 + } + }, + { + name: 'error in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.TimeStamp', + keyword: 'pattern' + }, + { + dataPath: '.TimeStamp', + keyword: 'format' + }, + { + dataPath: '.AccountID', + keyword: 'minLength' + }, + { + dataPath: '.AccountID', + keyword: 'pattern' + }, + { + dataPath: '.Skip', + keyword: 'minimum' + }, + { + dataPath: '.Number', + keyword: 'maximum' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TimeStamp': '2015-20-40T30:62:61.999+01:00', + 'AccountID': 'Not a UUID', + 'Skip': -1, + 'Number': 31 + } + }, + { + name: 'valid date-time format but not in expected Zulu time pattern (uses timezone)', + valid: false, + expect: { + errors: [{ + dataPath: '.TimeStamp', + keyword: 'pattern' + }] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TimeStamp': '2015-01-01T00:00:00.000+10:00', + 'Skip': 0, + 'Number': 1 + } + }, + { + name: 'valid Zulu time pattern, but not valid data-time format (30th Feb)', + valid: false, + expect: { + errors: [{ + dataPath: '.TimeStamp', + keyword: 'format' + }] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'TimeStamp': '2015-02-30T00:00:00.000Z', + 'Skip': 0, + 'Number': 1 + } + } + ], + ListAccounts: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientKey': VALID_SHA256 + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientKey': VALID_SHA256, + extra: true + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientKey', + keyword: 'minLength' + }, + { + dataPath: '.ClientKey', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientKey': 'Not a SHA256' + } + } + ], + ListDeletedAccounts: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientKey': VALID_SHA256 + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientKey': VALID_SHA256, + extra: '' + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientKey', + keyword: 'minLength' + }, + { + dataPath: '.ClientKey', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ClientKey': 'Not a SHA256' + } + } + ], + ListAddresses: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + extra: null + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 2 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': 'Not a device token', + 'SessionToken': 'Not a session token' + } + } + ], + SetAccountAddress: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': VALID_UUID, + 'AddressID': VALID_UUID + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': VALID_UUID, + 'AddressID': VALID_UUID, + extra: false + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 4 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.AccountID', + keyword: 'minLength' + }, + { + dataPath: '.AccountID', + keyword: 'pattern' + }, + { + dataPath: '.AddressID', + keyword: 'minLength' + }, + { + dataPath: '.AddressID', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': 'Not a UUID', + 'AddressID': 'Not a UUID' + } + } + ], + SetDefaultAccount: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': VALID_UUID + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': VALID_UUID, + extra: 'This is an extra undefined parameter' + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.AccountID', + keyword: 'minLength' + }, + { + dataPath: '.AccountID', + keyword: 'pattern' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'AccountID': 'Not a UUID' + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Account Commands', TEST_SUITE); diff --git a/node_server/schemas/AddAddress.json b/node_server/schemas/AddAddress.json new file mode 100644 index 0000000..a1e10a2 --- /dev/null +++ b/node_server/schemas/AddAddress.json @@ -0,0 +1,117 @@ +{ + "$id": "AddAddress", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "AddAddress", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/addaddress/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "AddressDescription", + "Address1", + "Town", + "PostCode", + "Country" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "AddressDescription": { + "allOf": [ + { + "minLength": 2, + "maxLength": 150, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "BuildingNameFlat": { + "allOf": [ + { + "minLength": 0, + "maxLength": 64, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "Address1": { + "allOf": [ + { + "minLength": 4, + "maxLength": 64, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "Address2": { + "allOf": [ + { + "minLength": 4, + "maxLength": 64, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "Town": { + "allOf": [ + { + "minLength": 2, + "maxLength": 32, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "County": { + "allOf": [ + { + "minLength": 3, + "maxLength": 32, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "PostCode": { + "allOf": [ + { + "minLength": 4, + "maxLength": 15, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/fullAlphaNumericDashSpace" + } + ] + }, + "Country": { + "example": "United Kingdom", + "type": "string", + "const": "United Kingdom" + }, + "PhoneNumber": { + "$ref": "defs/#/definitions/phoneNumber" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/AddCard.json b/node_server/schemas/AddCard.json new file mode 100644 index 0000000..2ea0487 --- /dev/null +++ b/node_server/schemas/AddCard.json @@ -0,0 +1,92 @@ +{ + "$id": "AddCard", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "AddCard", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/addcard/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ClientAccountName", + "UserImage", + "NameOnAccount", + "CardPAN", + "CardExpiry", + "CVV", + "BillingAddress", + "ClientKey" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ClientAccountName": { + "allOf": [ + { + "minLength": 2, + "maxLength": 150, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "UserImage": { + "$ref": "defs/#/definitions/imageType" + }, + "NameOnAccount": { + "allOf": [ + { + "minLength": 5, + "maxLength": 64, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/alphaSpace" + } + ] + }, + "CardPAN": { + "$ref": "defs/#/definitions/cardPAN" + }, + "CardValidFrom": { + "$ref": "defs/#/definitions/cardDate" + }, + "CardExpiry": { + "$ref": "defs/#/definitions/cardDate" + }, + "CVV": { + "allOf": [ + { + "minLength": 3, + "maxLength": 4 + }, + { + "$ref": "defs/#/definitions/numeric" + } + ] + }, + "IssueNumber": { + "allOf": [ + { + "minLength": 1, + "maxLength": 3 + }, + { + "$ref": "defs/#/definitions/numeric" + } + ] + }, + "BillingAddress": { + "$ref": "defs/#/definitions/uuid" + }, + "ClientKey": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/AddDevice.json b/node_server/schemas/AddDevice.json new file mode 100644 index 0000000..73fe05b --- /dev/null +++ b/node_server/schemas/AddDevice.json @@ -0,0 +1,65 @@ +{ + "$id": "AddDevice", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "AddDevice", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/adddevice/", + "type": "object", + "required": [ + "ClientName", + "Password", + "DeviceNumber", + "DeviceUuid", + "DeviceHardware", + "DeviceSoftware", + "Latitude", + "Longitude" + ], + "additionalProperties": false, + "properties": { + "ClientName": { + "$ref": "defs/#/definitions/ClientName" + }, + "Password": { + "$ref": "defs/#/definitions/sha256" + }, + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "DeviceUuid": { + "$ref": "defs/#/definitions/DeviceUuid" + }, + "DeviceHardware": { + "allOf": [ + { + "minLength": 0, + "maxLength": 75, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "DeviceSoftware": { + "allOf": [ + { + "minLength": 0, + "maxLength": 75, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + }, + "Mode": { + "$ref": "defs/#/definitions/testMode" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/AddImage.json b/node_server/schemas/AddImage.json new file mode 100644 index 0000000..4d78059 --- /dev/null +++ b/node_server/schemas/AddImage.json @@ -0,0 +1,32 @@ +{ + "$id": "AddImage", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "AddImage", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/addimage/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ImageFile", + "FileType", + "ImageType" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ImageFile": { + "$ref": "defs/#/definitions/base64Image" + }, + "FileType": { + "$ref": "defs/#/definitions/fileType" + }, + "ImageType": { + "$ref": "defs/#/definitions/imageType" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Authorise2FARequest.json b/node_server/schemas/Authorise2FARequest.json new file mode 100644 index 0000000..aef996f --- /dev/null +++ b/node_server/schemas/Authorise2FARequest.json @@ -0,0 +1,32 @@ +{ + "$id": "Authorise2FARequest", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Authorise2FARequest Command", + "description": "Schema for Authorise2FARequest command", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "RequestID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "RequestID": { + "allOf": [ + { + "$ref": "defs/#/definitions/lowerCaseHex" + }, + { + "minLength": 64, + "maxLength": 64 + } + ] + } + } +} \ No newline at end of file diff --git a/node_server/schemas/CancelPaymentRequest.json b/node_server/schemas/CancelPaymentRequest.json new file mode 100644 index 0000000..2985db8 --- /dev/null +++ b/node_server/schemas/CancelPaymentRequest.json @@ -0,0 +1,24 @@ +{ + "$id": "CancelPaymentRequest", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "CancelPaymentRequest", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/cancelpaymentrequest/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "TransactionID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "TransactionID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ChangePIN.json b/node_server/schemas/ChangePIN.json new file mode 100644 index 0000000..8df3efc --- /dev/null +++ b/node_server/schemas/ChangePIN.json @@ -0,0 +1,28 @@ +{ + "$id": "ChangePIN", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ChangePIN", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/changepin/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "DeviceAuthorisation", + "NewAuthorisation" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "DeviceAuthorisation": { + "$ref": "defs/#/definitions/sha256" + }, + "NewAuthorisation": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ChangePassword.json b/node_server/schemas/ChangePassword.json new file mode 100644 index 0000000..9e96c3b --- /dev/null +++ b/node_server/schemas/ChangePassword.json @@ -0,0 +1,28 @@ +{ + "$id": "ChangePassword", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ChangePassword", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/changepassword/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "Password", + "NewPassword" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "Password": { + "$ref": "defs/#/definitions/sha256" + }, + "NewPassword": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ConfirmInvoice.json b/node_server/schemas/ConfirmInvoice.json new file mode 100644 index 0000000..3756fd4 --- /dev/null +++ b/node_server/schemas/ConfirmInvoice.json @@ -0,0 +1,40 @@ +{ + "$id": "ConfirmInvoice", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ConfirmInvoice", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/invoice_commands/confirm_invoice/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ClientKey", + "InvoiceID", + "AccountID", + "Latitude", + "Longitude" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ClientKey": { + "$ref": "defs/#/definitions/sha256" + }, + "InvoiceID": { + "$ref": "defs/#/definitions/uuid" + }, + "AccountID": { + "$ref": "defs/#/definitions/uuid" + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ConfirmTransaction.json b/node_server/schemas/ConfirmTransaction.json new file mode 100644 index 0000000..0723234 --- /dev/null +++ b/node_server/schemas/ConfirmTransaction.json @@ -0,0 +1,31 @@ +{ + "$id": "ConfirmTransaction", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ConfirmTransaction", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/confirmtransaction/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "TransactionID", + "ClientKey" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "TransactionID": { + "$ref": "defs/#/definitions/uuid" + }, + "TipAmount": { + "$ref": "defs/#/definitions/tipAmount" + }, + "ClientKey": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/DeleteAccount.json b/node_server/schemas/DeleteAccount.json new file mode 100644 index 0000000..e801b9c --- /dev/null +++ b/node_server/schemas/DeleteAccount.json @@ -0,0 +1,24 @@ +{ + "$id": "DeleteAccount", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "DeleteAccount", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/deleteaccount/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "AccountID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "AccountID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/DeleteAddress.json b/node_server/schemas/DeleteAddress.json new file mode 100644 index 0000000..a76b5b3 --- /dev/null +++ b/node_server/schemas/DeleteAddress.json @@ -0,0 +1,24 @@ +{ + "$id": "DeleteAddress", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "DeleteAddress", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/deleteaddress/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "AddressID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "AddressID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/DeleteDevice.json b/node_server/schemas/DeleteDevice.json new file mode 100644 index 0000000..afa9ff1 --- /dev/null +++ b/node_server/schemas/DeleteDevice.json @@ -0,0 +1,28 @@ +{ + "$id": "DeleteDevice", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "DeleteDevice", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/deletedevice/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "Password", + "DeviceIndex" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "Password": { + "$ref": "defs/#/definitions/sha256" + }, + "DeviceIndex": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/DeleteMessage.json b/node_server/schemas/DeleteMessage.json new file mode 100644 index 0000000..1c90970 --- /dev/null +++ b/node_server/schemas/DeleteMessage.json @@ -0,0 +1,24 @@ +{ + "$id": "DeleteMessage", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "DeleteMessage", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/messaging_commands/deletemessage/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "MessageID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "MessageID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ElevateSession.json b/node_server/schemas/ElevateSession.json new file mode 100644 index 0000000..5305640 --- /dev/null +++ b/node_server/schemas/ElevateSession.json @@ -0,0 +1,28 @@ +{ + "$id": "ElevateSession", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ElevateSession", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/elevatesession/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ClientName", + "Password" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ClientName": { + "$ref": "defs/#/definitions/ClientName" + }, + "Password": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Get2FARequest.json b/node_server/schemas/Get2FARequest.json new file mode 100644 index 0000000..7e72b8e --- /dev/null +++ b/node_server/schemas/Get2FARequest.json @@ -0,0 +1,20 @@ +{ + "$id": "Get2FARequest", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Get2FARequest Command", + "description": "Schema for Get2FARequest command", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/GetClientDetails.json b/node_server/schemas/GetClientDetails.json new file mode 100644 index 0000000..b4a6c0b --- /dev/null +++ b/node_server/schemas/GetClientDetails.json @@ -0,0 +1,20 @@ +{ + "$id": "GetClientDetails", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "GetClientDetails", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/getclientdetails/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/GetImage.json b/node_server/schemas/GetImage.json new file mode 100644 index 0000000..63f6777 --- /dev/null +++ b/node_server/schemas/GetImage.json @@ -0,0 +1,24 @@ +{ + "$id": "GetImage", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "GetImage", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/getimage/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ImageRef" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ImageRef": { + "$ref": "defs/#/definitions/imageRef" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/GetInvoice.json b/node_server/schemas/GetInvoice.json new file mode 100644 index 0000000..132ba3f --- /dev/null +++ b/node_server/schemas/GetInvoice.json @@ -0,0 +1,24 @@ +{ + "$id": "GetInvoice", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "GetInvoice", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/invoice_commands/get_invoice/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "InvoiceID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "InvoiceID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/GetMessage.json b/node_server/schemas/GetMessage.json new file mode 100644 index 0000000..9cd6f6f --- /dev/null +++ b/node_server/schemas/GetMessage.json @@ -0,0 +1,24 @@ +{ + "$id": "GetMessage", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "GetMessage", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/messaging_commands/getmessage/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "MessageID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "MessageID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/GetTransactionDetail.json b/node_server/schemas/GetTransactionDetail.json new file mode 100644 index 0000000..7fb559f --- /dev/null +++ b/node_server/schemas/GetTransactionDetail.json @@ -0,0 +1,24 @@ +{ + "$id": "GetTransactionDetail", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "GetTransactionDetail", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/gettransactiondetail/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "TransactionID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "TransactionID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/GetTransactionHistory.json b/node_server/schemas/GetTransactionHistory.json new file mode 100644 index 0000000..fd060c7 --- /dev/null +++ b/node_server/schemas/GetTransactionHistory.json @@ -0,0 +1,41 @@ +{ + "$id": "GetTransactionHistory", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "GetTransactionHistory", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/gettransactionhistory/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "Skip", + "Number" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "TimeStamp": { + "$ref": "defs/#/definitions/timeStamp" + }, + "AccountID": { + "$ref": "defs/#/definitions/uuid" + }, + "Skip": { + "example": "0", + "type": "number", + "maxDecimalPlaces": 0, + "minimum": 0 + }, + "Number": { + "example": "0", + "type": "number", + "maxDecimalPlaces": 0, + "minimum": 1, + "maximum": 30 + } + } +} \ No newline at end of file diff --git a/node_server/schemas/GetTransactionUpdate.json b/node_server/schemas/GetTransactionUpdate.json new file mode 100644 index 0000000..c307d89 --- /dev/null +++ b/node_server/schemas/GetTransactionUpdate.json @@ -0,0 +1,24 @@ +{ + "$id": "GetTransactionUpdate", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "GetTransactionUpdate", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/gettransactionupdate/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "TransactionID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "TransactionID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/IconCache.json b/node_server/schemas/IconCache.json new file mode 100644 index 0000000..74f5645 --- /dev/null +++ b/node_server/schemas/IconCache.json @@ -0,0 +1,9 @@ +{ + "$id": "IconCache", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "IconCache", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/iconcache/", + "type": "object", + "additionalProperties": false, + "properties": {} +} \ No newline at end of file diff --git a/node_server/schemas/ImageCache.json b/node_server/schemas/ImageCache.json new file mode 100644 index 0000000..40577d4 --- /dev/null +++ b/node_server/schemas/ImageCache.json @@ -0,0 +1,20 @@ +{ + "$id": "ImageCache", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ImageCache", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/imagecache/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ImageCommands.spec.js b/node_server/schemas/ImageCommands.spec.js new file mode 100644 index 0000000..a94299d --- /dev/null +++ b/node_server/schemas/ImageCommands.spec.js @@ -0,0 +1,312 @@ +/** + * @fileOverview Unit tests for the schemas for the Image commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/} + */ +var _ = require('lodash'); +var testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_IMAGE_REF = '0123456789abcdef01234567'; + +// Base 64 encoding of 'address-book' icon from http://p.yusukekamiyamane.com/ +// via http://davidbcalhoun.com/2011/when-to-base64-encode-images-and-when-not-to/ +const VALID_BASE64_IMAGE = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA' + + 'GXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAp1JREFUeNqEU21IU1EYfu7' + + 'unW5Ty6aBszYs6MeUjGVYokHYyH5E1B9rZWFEFPQnAwmy6Hc/oqhfJsRKSSZGH1JIIX3MNC' + + 'sqLTD9o1Oj6ebnnDfvvefezrnbdCHhCw/n433P8z7nPe/hBEEAtX0U7hc164uwuvVSXKwZL' + + 'oOmaRDim+7m9vZa0WiEKSUFFpNpCWlmMyypqTDRuYn6t3k8vmQ2gRDCxs0t9fW45F52aBTR' + + 'OJLtZl7nEZad2m+KtoQCQ0FBARyOCGRZ/q92I1WgqqXlfdd95VsrK8/pChIEqqpCkiQsiCI' + + 'I0aBQZZoWl8lzFDwsFjMl0DBLY8Lj41hBwK4jSQrWOIphL6xYyhwJDWGo6wFSaH1Y3PTCAs' + + 'ITE1oyAa8flhWkbSiCLX8vun11eiGIpiJ/z2nYdx5HqLdVV7elrOzsuqysL3rmBIGiKPizK' + + 'CHHWY4PLVeQbnXAdegqdhy+hu8dDTBnbqQJZJ1A7u+vz7RaiymWCZgCRSF6Edk8b9cx+B/W' + + '6WuVxPaZnyiqXoPpyUmVYvkKTIFClHigEieKjYuSvETUllaF4GAUM1NT6ooaJDKx+aDfC9f' + + 'Byxj90REb+9ppmIoAscH/6leg8MS9DJXPAM9xHCM443K57C6biMjcHDaVVCHw9RmCA2/RGC' + + '5C00AqXk/m4p20HZK4CM/J3Zk9n0ecMBhDQnJHcrTisyMfdQXOilrdMfxcwoHq/fg5R59Ti' + + 'QV3hYGKo6X2J/c7LyQIjOx9GXhOw/zoJ8wEevRGyp53o/lGMNYsBgPtEwLecwov7/jGDKa1' + + 'twT6o3KpL4MdZgGsWZLtfPr7f1q58k1JNHy7YYaM+J+K3Y2PmAIbRavX66229hrGVvvL5uz' + + 'sHDEUvUu+NT1my78CDAAMK1a8/QaZCgAAAABJRU5ErkJggg=='; + +// Build a string that is 50,001 characters long, which is > than the limit +const INVALID_BASE64_TOO_LONG = _.repeat('a', 50001); + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + AddImage: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageFile': VALID_BASE64_IMAGE, + 'FileType': 'JPEG', + 'ImageType': 'Selfie' + } + }, + { + name: 'with extra param', + valid: false, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageFile': VALID_BASE64_IMAGE, + 'FileType': 'JPEG', + 'ImageType': 'Selfie', + extra: 'no' + } + }, + { + name: 'no required params', + valid: false, + expect: { + missingRequiredCount: 5 + }, + data: {} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ImageFile', + keyword: 'pattern' + }, + { + dataPath: '.FileType', + keyword: 'enum' + }, + { + dataPath: '.ImageType', + keyword: 'enum' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageFile': 'a===', // Too many `=` padding chars + 'FileType': 'GIF', + 'ImageType': 'defaultSelfie' + } + }, + { + name: 'image too long', + valid: false, + expect: { + errors: [ + { + dataPath: '.ImageFile', + keyword: 'maxLength' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageFile': INVALID_BASE64_TOO_LONG, + 'FileType': 'JPEG', + 'ImageType': 'Selfie' + } + }, + { + name: 'image too short (less than 4 chars)', + valid: false, + expect: { + errors: [ + { + dataPath: '.ImageFile', + keyword: 'minLength' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageFile': 'abc', + 'FileType': 'JPEG', + 'ImageType': 'Selfie' + } + } + ], + GetImage: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': VALID_IMAGE_REF + } + }, + { + name: 'With defaultSelfie define.', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': 'defaultSelfie' + } + }, + { + name: 'With defaultCompanyLogo0 define.', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': 'defaultCompanyLogo0' + } + }, + { + name: 'no required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ImageRef', + keyword: 'minLength' + }, + { + dataPath: '.ImageRef', + keyword: 'pattern' + }, + { + dataPath: '.ImageRef', + keyword: 'enum' + }, + { + dataPath: '.ImageRef', + keyword: 'oneOf' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': 'A' + } + } + ], + IconCache: [ + { + name: '', + valid: true, + data: {} + }, + { + name: 'additionalProperties', + valid: false, + expect: { + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + } + ], + ImageCache: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN + } + }, + { + name: 'no required params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + } + ], + ReportImage: [ + { + name: '', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': VALID_IMAGE_REF + } + }, + { + name: 'With defaultSelfie define.', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': 'defaultSelfie' + } + }, + { + name: 'With defaultCompanyLogo0 define.', + valid: true, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': 'defaultCompanyLogo0' + } + }, + { + name: 'no required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ImageRef', + keyword: 'maxLength' + }, + { + dataPath: '.ImageRef', + keyword: 'pattern' + }, + { + dataPath: '.ImageRef', + keyword: 'enum' + }, + { + dataPath: '.ImageRef', + keyword: 'oneOf' + } + ] + }, + data: { + 'DeviceToken': VALID_DEVICE_TOKEN, + 'SessionToken': VALID_SESSION_TOKEN, + 'ImageRef': '0123456789abcdef01234567z' + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Image Commands', TEST_SUITE); diff --git a/node_server/schemas/InvoiceCommands.spec.js b/node_server/schemas/InvoiceCommands.spec.js new file mode 100644 index 0000000..d20316d --- /dev/null +++ b/node_server/schemas/InvoiceCommands.spec.js @@ -0,0 +1,268 @@ +/** + * @fileOverview Unit tests for the schemas for the invoice commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/invoice_commands/} + */ +var _ = require('lodash'); +var testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_UUID = '0123456789abcdef01234567'; +const VALID_SHA256 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + ConfirmInvoice: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + ClientKey: VALID_SHA256, + InvoiceID: VALID_UUID, + AccountID: VALID_UUID, + Latitude: 0.0, + Longitude: 0.0 + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 7, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientKey', + keyword: 'minLength' + }, + { + dataPath: '.ClientKey', + keyword: 'pattern' + }, + { + dataPath: '.InvoiceID', + keyword: 'minLength' + }, + { + dataPath: '.InvoiceID', + keyword: 'pattern' + }, + { + dataPath: '.AccountID', + keyword: 'minLength' + }, + { + dataPath: '.AccountID', + keyword: 'pattern' + }, + { + dataPath: '.Latitude', + keyword: 'maximum' + }, + { + dataPath: '.Longitude', + keyword: 'minimum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + ClientKey: 'Not a sha256', + InvoiceID: 'Not a UUID', + AccountID: 'Not a UUID', + Latitude: 90.1, + Longitude: -180.1 + } + } + ], + GetInvoice: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + InvoiceID: VALID_UUID + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.InvoiceID', + keyword: 'type' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + InvoiceID: 1234567890 + } + } + ], + ListInvoices: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + ModifiedSince: '2015-03-02T17:52:40.000Z', + Skip: 0, + Number: 1 + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ModifiedSince', + keyword: 'pattern' + }, + { + dataPath: '.ModifiedSince', + keyword: 'format' + }, + { + dataPath: '.Skip', + keyword: 'minimum' + }, + { + dataPath: '.Number', + keyword: 'type' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + ModifiedSince: 'Tue, 03 Mar 2015 17:52:40 GMT', + Skip: -1, + Number: '30' + } + } + ], + RejectInvoice: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + InvoiceID: VALID_UUID, + Comment: 'Wrong price' + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.InvoiceID', + keyword: 'type' + }, + { + dataPath: '.Comment', + keyword: 'ensureTrim' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + InvoiceID: 1234567890, + Comment: ' Not trimmed! ' + } + }, + { + name: 'comment too short', + valid: false, + expect: { + errors: [ + { + dataPath: '.Comment', + keyword: 'minLength' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + InvoiceID: VALID_UUID, + Comment: '' + } + }, + { + name: 'comment too long', + valid: false, + expect: { + errors: [ + { + dataPath: '.Comment', + keyword: 'maxLength' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + InvoiceID: VALID_UUID, + Comment: _.repeat('a', 301) + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Invoice Commands', TEST_SUITE); diff --git a/node_server/schemas/KeepAlive.json b/node_server/schemas/KeepAlive.json new file mode 100644 index 0000000..efb99f7 --- /dev/null +++ b/node_server/schemas/KeepAlive.json @@ -0,0 +1,20 @@ +{ + "$id": "KeepAlive", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "KeepAlive Command", + "description": "Schema for KeepAlive command", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ListAccounts.json b/node_server/schemas/ListAccounts.json new file mode 100644 index 0000000..7ce136f --- /dev/null +++ b/node_server/schemas/ListAccounts.json @@ -0,0 +1,24 @@ +{ + "$id": "ListAccounts", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ListAccounts", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/listaccounts/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ClientKey" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ClientKey": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ListAddresses.json b/node_server/schemas/ListAddresses.json new file mode 100644 index 0000000..13873f7 --- /dev/null +++ b/node_server/schemas/ListAddresses.json @@ -0,0 +1,20 @@ +{ + "$id": "ListAddresses", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ListAddresses", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/listaddresses/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ListDeletedAccounts.json b/node_server/schemas/ListDeletedAccounts.json new file mode 100644 index 0000000..5db3321 --- /dev/null +++ b/node_server/schemas/ListDeletedAccounts.json @@ -0,0 +1,24 @@ +{ + "$id": "ListDeletedAccounts", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ListDeletedAccounts", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/listaccounts/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ClientKey" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ClientKey": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ListDevices.json b/node_server/schemas/ListDevices.json new file mode 100644 index 0000000..861bdeb --- /dev/null +++ b/node_server/schemas/ListDevices.json @@ -0,0 +1,20 @@ +{ + "$id": "ListDevices", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ListDevices", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/listdevices/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ListInvoices.json b/node_server/schemas/ListInvoices.json new file mode 100644 index 0000000..45eabdc --- /dev/null +++ b/node_server/schemas/ListInvoices.json @@ -0,0 +1,36 @@ +{ + "$id": "ListInvoices", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ListInvoices", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/invoice_commands/list_invoices/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ModifiedSince": { + "$ref": "defs/#/definitions/timeStamp" + }, + "Skip": { + "type": "number", + "maxDecimalPlaces": 0, + "minimum": 0, + "default": 0 + }, + "Number": { + "type": "number", + "maxDecimalPlaces": 0, + "minimum": 1, + "maximum": 30, + "default": 30 + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ListItems.json b/node_server/schemas/ListItems.json new file mode 100644 index 0000000..cd81a76 --- /dev/null +++ b/node_server/schemas/ListItems.json @@ -0,0 +1,23 @@ +{ + "$id": "ListItems", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ListItems", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/merchant_commands/list_items/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ModifiedSince": { + "$ref": "defs/#/definitions/timeStamp" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ListMessages.json b/node_server/schemas/ListMessages.json new file mode 100644 index 0000000..c6b9dbc --- /dev/null +++ b/node_server/schemas/ListMessages.json @@ -0,0 +1,38 @@ +{ + "$id": "ListMessages", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ListMessages", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/messaging_commands/listmessages/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "Skip": { + "type": "number", + "maxDecimalPlaces": 0, + "minimum": 0 + }, + "Number": { + "type": "number", + "maxDecimalPlaces": 0, + "minimum": 1, + "maximum": 30 + }, + "Type": { + "type": "string", + "enum": [ + "Login", + "Info" + ] + } + } +} \ No newline at end of file diff --git a/node_server/schemas/LogOut1.json b/node_server/schemas/LogOut1.json new file mode 100644 index 0000000..ef0b3c9 --- /dev/null +++ b/node_server/schemas/LogOut1.json @@ -0,0 +1,20 @@ +{ + "$id": "LogOut1", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "LogOut1 Command", + "description": "Schema for LogOut1 command", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Login1.json b/node_server/schemas/Login1.json new file mode 100644 index 0000000..5dada42 --- /dev/null +++ b/node_server/schemas/Login1.json @@ -0,0 +1,62 @@ +{ + "$id": "Login1", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Login1 Command", + "description": "Schema for Login1 command", + "type": "object", + "required": [ + "ClientName", + "DeviceToken", + "DeviceAuthorisation", + "APIVersion", + "DeviceHardware", + "DeviceSoftware", + "Latitude", + "Longitude" + ], + "additionalProperties": false, + "properties": { + "ClientName": { + "$ref": "defs/#/definitions/email" + }, + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "DeviceAuthorisation": { + "$ref": "defs/#/definitions/sha256" + }, + "APIVersion": { + "$ref": "defs/#/definitions/version" + }, + "DeviceHardware": { + "allOf": [ + { + "$ref": "defs/#/definitions/generalTextSpace" + }, + { + "minLength": 0, + "maxLength": 75, + "ensureTrim": true + } + ] + }, + "DeviceSoftware": { + "allOf": [ + { + "$ref": "defs/#/definitions/generalTextSpace" + }, + { + "minLength": 0, + "maxLength": 75, + "ensureTrim": true + } + ] + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Login1.spec.js b/node_server/schemas/Login1.spec.js new file mode 100644 index 0000000..26a4f37 --- /dev/null +++ b/node_server/schemas/Login1.spec.js @@ -0,0 +1,178 @@ +/** + * @fileOverview Unit tests for the Login1 schema definition + */ +/* globals describe, beforeEach, it */ + +var _ = require('lodash'); +var validator = require('./validator.js'); +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); +var expect = chai.expect; + +chai.use(chaiAsPromised); + +/** + * Set the path prefix to a value that will work for these tests + */ +const SCHEMA_ROOT = '../schemas'; + +/** + * A const default body demonstrating a valid Login1 command + */ +const validLogin1 = { + ClientName: 'someone@example.com', + DeviceToken: '01234567890abcdefghijklmnopqrstuvwxyzABCDE', + DeviceAuthorisation: '01234567890abcdef01234567890abcdef01234567890abcdef01234567890ab', + APIVersion: '0.0', + DeviceHardware: 'Chai Tests', + DeviceSoftware: 'Chai Tests', + Latitude: -90.0, + Longitude: 180.0 +}; + +describe('Login1 Schema', function() { + /** + * Initialise the validator with the right schema + */ + beforeEach(function() { + validator.initialise(['Login1'], true, SCHEMA_ROOT); + }); + + /** + * Test the schema is correct + */ + it('should validate a properly formatted body', function() { + return expect( + validator.validate('Login1', validLogin1) + ).to.eventually.be.true; + }); + + it('should reject an invalid ClientName', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.ClientName = 'not-an-address'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a DeviceToken with invalid chars', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.DeviceToken = '!?234567890abcdefghijklmnopqrstuvwxyzABCDE'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a DeviceAuth with invalid chars', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.DeviceAuthorisation = 'AB234567890abcdef01234567890abcdef01234567890abcdef01234567890ab'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a APIVersion with invalid chars', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.APIVersion = 'A.B'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a DeviceHardware with invalid chars', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.DeviceHardware = '`'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a DeviceHardware that isnt trimmed', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.DeviceHardware = ' Not Trimmed '; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a DeviceSoftware with invalid chars', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.DeviceSoftware = '~'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a DeviceSoftware that isnt trimmed', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.DeviceSoftware = ' Not Trimmed '; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a Latitude outside the valid range', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Latitude = 90.00000001; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a Latitude formatted as a string', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Latitude = '0.1'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should accept a Latitude with exactly 8 decimal places', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Latitude = 0.12345678; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.fulfilled; + }); + + it('should reject a Latitude with >8 decimal places', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Latitude = 0.123456789; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a Longitude outside the valid range', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Latitude = -180.00000001; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a Longitude formatted as a string', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Longitude = '0.1'; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should accept a Longitude with exactly 8 decimal places', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Longitude = 0.12345678; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.fulfilled; + }); + + it('should reject a Longitude with >8 decimal places', function() { + var invalidLogin1 = _.clone(validLogin1); + invalidLogin1.Longitude = 0.123456789; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + +}); diff --git a/node_server/schemas/LoginAuth.spec.js b/node_server/schemas/LoginAuth.spec.js new file mode 100644 index 0000000..b72d8f7 --- /dev/null +++ b/node_server/schemas/LoginAuth.spec.js @@ -0,0 +1,409 @@ +/** + * @fileOverview Unit tests for the schemas for the Login & Authorisation commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/} + */ +/* globals describe, beforeEach, it */ + +const testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +// eslint-disable-next-line id-match +const VALID_SHA256 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + Login1: [ + { + name: '', + valid: true, + data: { + ClientName: 'someone@example.com', + DeviceToken: VALID_DEVICE_TOKEN, + DeviceAuthorisation: VALID_SHA256, + APIVersion: '0.0', + DeviceHardware: 'Chai Tests', + DeviceSoftware: 'Chai Tests', + Latitude: -90.0, + Longitude: 180.0 + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 8, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'Missing ClientName', + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + DeviceAuthorisation: VALID_SHA256, + APIVersion: '0.0', + DeviceHardware: 'Chai Tests', + DeviceSoftware: 'Chai Tests', + Latitude: -90.0, + Longitude: 180.0 + } + } + ], + AcceptEULA: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'Missing DeviceToken', + valid: false, + data: { + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'SessionToken too long', + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: '01234567890abcdefghijklmnopqrstuvwxyzABCDEF' + } + } + ], + Authorise2FARequest: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + RequestID: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'RequestID missing', + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'RequestID too long', + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + RequestID: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0' + } + }, + { + name: 'RequestID invalid char', + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + RequestId: 'A123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + } + } + ], + ElevateSession: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + ClientName: 'a@example.com', + Password: VALID_SHA256 + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 4, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.ClientName', + keyword: 'pattern' + }, + { + dataPath: '.Password', + keyword: 'minLength' + }, + { + dataPath: '.Password', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: 'a', + SessionToken: 'b', + ClientName: 'John Smith', + Password: 'Not a SHA 256' + } + } + ], + Get2FARequest: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'Missing SessionToken', + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN + } + }, + { + name: 'DeviceToken invalid', + valid: false, + data: { + DeviceToken: '!1234567890abcdefghijklmnopqrstuvwxyzABCDE', + SessionToken: VALID_SESSION_TOKEN + } + } + ], + KeepAlive: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'DeviceToken too short', + valid: false, + data: { + DeviceToken: '1234567890abcdefghijklmnopqrstuvwxyzABCDE', + SessionToken: VALID_SESSION_TOKEN + } + } + ], + LogOut1: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'SessionToken empty', + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: '' + } + } + ], + PINReset: [ + { + name: '', + valid: true, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: '+447700923456', + DeviceUuid: 'A device ID of no particular significance!', + Latitude: 0.0, + Longitude: 0.0, + DeviceAuthorisation: VALID_SHA256 + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 7, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'Phone number too short', + valid: false, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: '+447700', + DeviceUuid: 'A device ID of no particular significance!', + Latitude: 0.0, + Longitude: 0.0, + DeviceAuthorisation: VALID_SHA256 + } + }, + { + name: 'Password not hashed', + valid: false, + data: { + ClientName: 'a@example.com', + Password: 'Oops, my actual password', + DeviceNumber: '+447700923456', + DeviceUuid: 'A device ID of no particular significance!', + Latitude: 0.0, + Longitude: 0.0, + DeviceAuthorisation: VALID_SHA256 + } + }, + { + name: 'Invalid DeviceUuid', + valid: false, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: '+447700923456', + DeviceUuid: '`~ and some chars to make it long enough', + Latitude: 0.0, + Longitude: 0.0, + DeviceAuthorisation: VALID_SHA256 + } + }, + { + name: 'Missing Latitude', + valid: false, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: '+447700923456', + DeviceUuid: 'A device ID of no particular significance!', + Longitude: 0.0, + DeviceAuthorisation: VALID_SHA256 + } + }, + { + name: 'Longitude out of range', + valid: false, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: '+447700923456', + DeviceUuid: 'A device ID of no particular significance!', + Latitude: 0.0, + Longitude: 180.00000001, + DeviceAuthorisation: VALID_SHA256 + } + }, + { + name: 'Invalid DeviceAuthorisation', + valid: false, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: '+447700923456', + DeviceUuid: 'A device ID of no particular significance!', + Latitude: 0.0, + Longitude: 0.0, + DeviceAuthorisation: 'Something wrong here' + } + } + ], + RotateHMAC: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'Empty body', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + } + ] + +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Login and Authentication', TEST_SUITE); diff --git a/node_server/schemas/MarkMessage.json b/node_server/schemas/MarkMessage.json new file mode 100644 index 0000000..0a355b1 --- /dev/null +++ b/node_server/schemas/MarkMessage.json @@ -0,0 +1,32 @@ +{ + "$id": "MarkMessage", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "MarkMessage", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/messaging_commands/markmessage/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "MessageID", + "Mark" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "MessageID": { + "$ref": "defs/#/definitions/uuid" + }, + "Mark": { + "type": "string", + "enum": [ + "Unread", + "Read" + ] + } + } +} \ No newline at end of file diff --git a/node_server/schemas/MerchantCommands.spec.js b/node_server/schemas/MerchantCommands.spec.js new file mode 100644 index 0000000..f98d381 --- /dev/null +++ b/node_server/schemas/MerchantCommands.spec.js @@ -0,0 +1,65 @@ +/** + * @fileOverview Unit tests for the schemas for the merchant commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/merchant_commands/} + */ +var _ = require('lodash'); +var testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + ListItems: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + ModifiedSince: '2015-03-02T17:52:40.000Z' + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ModifiedSince', + keyword: 'pattern' + }, + { + dataPath: '.ModifiedSince', + keyword: 'format' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + ModifiedSince: 'T03 Mar 2015 17:52:40 GMT' + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Merchant Commands', TEST_SUITE); diff --git a/node_server/schemas/MessageCommands.spec.js b/node_server/schemas/MessageCommands.spec.js new file mode 100644 index 0000000..094f685 --- /dev/null +++ b/node_server/schemas/MessageCommands.spec.js @@ -0,0 +1,203 @@ +/** + * @fileOverview Unit tests for the schemas for the message commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/message_commands/} + */ +var _ = require('lodash'); +var testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_UUID = '0123456789abcdef01234567'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + DeleteMessage: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + MessageID: VALID_UUID + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.MessageID', + keyword: 'minLength' + }, + { + dataPath: '.MessageID', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + MessageID: 'Not a UUID' + } + } + ], + GetMessage: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + MessageID: VALID_UUID + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.MessageID', + keyword: 'minLength' + }, + { + dataPath: '.MessageID', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + MessageID: 'Not a UUID' + } + } + ], + ListMessages: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + Skip: 9999, + Number: 30, + Type: 'Login' + } + }, + { + name: 'missing required params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.Skip', + keyword: 'minimum' + }, + { + dataPath: '.Number', + keyword: 'maximum' + }, + { + dataPath: '.Type', + keyword: 'enum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + Skip: -1, + Number: 31, + Type: 'All' + } + } + ], + MarkMessage: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + MessageID: VALID_UUID, + Mark: 'Unread' + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 4, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.MessageID', + keyword: 'minLength' + }, + { + dataPath: '.MessageID', + keyword: 'pattern' + }, + { + dataPath: '.Mark', + keyword: 'enum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + MessageID: 'Not a UUID', + Mark: 'Important' + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Message Commands', TEST_SUITE); diff --git a/node_server/schemas/PINReset.json b/node_server/schemas/PINReset.json new file mode 100644 index 0000000..b398432 --- /dev/null +++ b/node_server/schemas/PINReset.json @@ -0,0 +1,49 @@ +{ + "$id": "PINReset", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "PINReset Command", + "description": "Schema for PINReset command", + "type": "object", + "required": [ + "ClientName", + "Password", + "DeviceNumber", + "DeviceUuid", + "Latitude", + "Longitude", + "DeviceAuthorisation" + ], + "additionalProperties": false, + "properties": { + "ClientName": { + "$ref": "defs/#/definitions/email" + }, + "Password": { + "$ref": "defs/#/definitions/sha256" + }, + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "DeviceUuid": { + "allOf": [ + { + "$ref": "defs/#/definitions/generalTextSpace" + }, + { + "minLength": 30, + "maxLength": 150, + "ensureTrim": true + } + ] + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + }, + "DeviceAuthorisation": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/PayCodeRequest.json b/node_server/schemas/PayCodeRequest.json new file mode 100644 index 0000000..c10edf5 --- /dev/null +++ b/node_server/schemas/PayCodeRequest.json @@ -0,0 +1,32 @@ +{ + "$id": "PayCodeRequest", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "PayCodeRequest", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/paycoderequest/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "AccountID", + "Latitude", + "Longitude" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "AccountID": { + "$ref": "defs/#/definitions/uuid" + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/PaymentCommands.spec.js b/node_server/schemas/PaymentCommands.spec.js new file mode 100644 index 0000000..3368d20 --- /dev/null +++ b/node_server/schemas/PaymentCommands.spec.js @@ -0,0 +1,984 @@ +/** + * @fileOverview Unit tests for the schemas for the payment commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/} + */ +const _ = require('lodash'); +const testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +// eslint-disable-next-line id-match +const VALID_SHA256 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +const VALID_UUID = '0123456789abcdef01234567'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + * Note that the MerchantInvoice params `Item_xyz` are not camel case, so add + * a jshint ignore for them. + */ +/* jshint -W106 */ +const TEST_SUITE = { + CancelPaymentRequest: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: VALID_UUID + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.TransactionID', + keyword: 'minLength' + }, + { + dataPath: '.TransactionID', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: 'Not a UUID' + } + } + ], + ConfirmTransaction: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: VALID_UUID, + TipAmount: 0, + ClientKey: VALID_SHA256 + } + }, + { + name: 'with only required params', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: VALID_UUID, + ClientKey: VALID_SHA256 + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 4, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.TransactionID', + keyword: 'minLength' + }, + { + dataPath: '.TransactionID', + keyword: 'pattern' + }, + { + dataPath: '.TipAmount', + keyword: 'minimum' + }, + { + dataPath: '.ClientKey', + keyword: 'minLength' + }, + { + dataPath: '.ClientKey', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: 'Not a UUID', + TipAmount: -1, + ClientKey: 'Not a SHA256' + } + } + ], + GetTransactionUpdate: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: VALID_UUID + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.TransactionID', + keyword: 'minLength' + }, + { + dataPath: '.TransactionID', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: 'Not a UUID' + } + } + ], + PayCodeRequest: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 5, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.AccountID', + keyword: 'minLength' + }, + { + dataPath: '.AccountID', + keyword: 'pattern' + }, + { + dataPath: '.Latitude', + keyword: 'maximum' + }, + { + dataPath: '.Longitude', + keyword: 'minimum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: 'Not a UUID', + Longitude: -181, + Latitude: 90.00000001 + } + }, + { + name: 'NULL lat/long', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: null, + Longitude: null + } + }, + { + name: 'mixed lat/long', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: null + } + }, + { + name: 'mixed lat/long 2', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: null, + Longitude: 0 + } + }, + { + name: 'out of range lat/long (low)', + valid: false, + expect: { + errors: [ + { + dataPath: '.Latitude', + keyword: 'minimum' + }, + { + dataPath: '.Longitude', + keyword: 'minimum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: -90.00000001, + Longitude: -180.0000001 + } + }, + { + name: 'out of range lat/long (high)', + valid: false, + expect: { + errors: [ + { + dataPath: '.Latitude', + keyword: 'maximum' + }, + { + dataPath: '.Longitude', + keyword: 'maximum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: 90.00000001, + Longitude: 180.0000001 + } + }, + { + name: 'out of range lat/long (too many decimal places)', + valid: false, + expect: { + errors: [ + { + dataPath: '.Latitude', + keyword: 'maxDecimalPlaces' + }, + { + dataPath: '.Longitude', + keyword: 'maxDecimalPlaces' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: 1.000000001, + Longitude: 1.000000001 + } + }, + { + name: 'wrong type lat/long (high)', + valid: false, + expect: { + errors: [ + { + dataPath: '.Latitude', + keyword: 'type' + }, + { + dataPath: '.Latitude', + keyword: 'type' + }, + { + dataPath: '.Latitude', + keyword: 'oneOf' + }, + { + dataPath: '.Longitude', + keyword: 'type' + }, + { + dataPath: '.Longitude', + keyword: 'type' + }, + { + dataPath: '.Longitude', + keyword: 'oneOf' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + AccountID: VALID_UUID, + Latitude: '0', + Longitude: 'unknown' + } + } + ], + RefundTransaction: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: VALID_UUID, + Latitude: 0, + Longitude: 0, + ClientKey: VALID_SHA256 + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 6, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.TransactionID', + keyword: 'minLength' + }, + { + dataPath: '.TransactionID', + keyword: 'pattern' + }, + { + dataPath: '.Latitude', + keyword: 'maximum' + }, + { + dataPath: '.Longitude', + keyword: 'minimum' + }, + { + dataPath: '.ClientKey', + keyword: 'minLength' + }, + { + dataPath: '.ClientKey', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + TransactionID: 'Not a UUID', + Longitude: -181, + Latitude: 90.00000001, + ClientKey: 'Not a SHA256' + } + } + ], + RedeemPayCode: [ + { + name: 'without a merchantInvoice', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantComment: 'You were served today by Stuey.', + RequestAmount: 0, + RequestTip: 1, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'without a merchantInvoice and without a tip request field', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantComment: 'You were served today by Stuey.', + RequestAmount: 0, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'no params', + valid: false, + expect: { + missingRequiredCount: 7, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param (without merchantInvoice)', + valid: false, + expect: { + errors: [ + { + dataPath: '.PayCode', + keyword: 'minLength' + }, + { + dataPath: '.PayCode', + keyword: 'pattern' + }, + { + dataPath: '.MerchantComment', + keyword: 'minLength' + }, + { + dataPath: '.RequestAmount', + keyword: 'minimum' + }, + { + dataPath: '.RequestTip', + keyword: 'enum' + }, + { + dataPath: '.AccountID', + keyword: 'minLength' + }, + { + dataPath: '.AccountID', + keyword: 'pattern' + }, + { + dataPath: '.Latitude', + keyword: 'type' + }, + { + dataPath: '.Latitude', + keyword: 'type' + }, + { + dataPath: '.Latitude', + keyword: 'oneOf' + }, + { + dataPath: '.Longitude', + keyword: 'maximum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: 'I', + MerchantComment: '', + RequestAmount: -1, + RequestTip: 2, + AccountID: 'Not a UUID', + Latitude: '90', + Longitude: 360 + } + }, + { + name: 'more errors in parameters (without merchantInvoice)', + valid: false, + expect: { + errors: [ + { + dataPath: '.PayCode', + keyword: 'minLength' + }, + { + dataPath: '.PayCode', + keyword: 'pattern' + }, + { + dataPath: '.MerchantComment', + keyword: 'maxLength' + }, + { + dataPath: '.MerchantComment', + keyword: 'ensureTrim' + }, + { + dataPath: '.RequestTip', + keyword: 'type' + }, + { + dataPath: '.RequestTip', + keyword: 'enum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: 'O', + MerchantComment: ' ' + _.repeat('a', 300), + RequestAmount: 0, + RequestTip: true, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with merchantInvoice (with logical max limits on numbers; though functionally incorrect)', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [ + { + Item_ID: '764aa907908f72332093c651', + Item_Code: '98768926735178', + Item_Description: '10cm Brush', + Item_VATCode: 'T1', + Item_VATRate: 10000, + Item_NetAmount: 25000, + Item_GrossAmount: 25000, + Item_Quantity: 32000, + Line_VATAmount: 25000, + Line_TotalAmount: 25000 + } + ], + RequestAmount: 399, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with merchantInvoice with pseudo-optionals (null, "", etc.), and min limits', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [ + { + Item_ID: null, + Item_Code: '', + Item_Description: 'A', + Item_VATCode: '', + Item_VATRate: 0, + Item_NetAmount: 0, + Item_GrossAmount: 0, + Item_Quantity: 1, + Line_TotalAmount: 0, + Line_VATAmount: 0 + } + ], + RequestAmount: 0, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with merchantInvoice with pseudo-optionals (null, "", etc.), and min limits 2', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [ + { + Item_ID: null, + Item_Code: '', + Item_Description: 'A', + Item_VATCode: '', + Item_VATRate: 0, + Item_NetAmount: null, + Item_GrossAmount: null, + Item_Quantity: 1, + Line_TotalAmount: 0, + Line_VATAmount: 0 + } + ], + RequestAmount: 0, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with a merchantInvoice with empty array', + valid: false, + expect: { + errors: [ + { + dataPath: '.MerchantInvoice', + keyword: 'minItems' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [], + RequestAmount: 399, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with a merchantInvoice with empty invoice item', + valid: false, + expect: { + missingRequiredCount: 10 + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [{}], + RequestAmount: 399, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with a merchantInvoice with invoice item that only has invalid additional prop', + valid: false, + expect: { + missingRequiredCount: 10, + additionalPropsCount: 1 + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [{invalidAdditionalProp: 1}], + RequestAmount: 399, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with a merchantInvoice with invalid param values', + valid: false, + expect: { + errors: [ + { + dataPath: '.MerchantInvoice[0].Item_ID', + keyword: 'type' // Doesn't match `null` + }, + { + dataPath: '.MerchantInvoice[0].Item_ID', + keyword: 'minLength' // Doesn't match uuid length + }, + { + dataPath: '.MerchantInvoice[0].Item_ID', + keyword: 'pattern' // Doesn't match uuid pattern + }, + { + dataPath: '.MerchantInvoice[0].Item_ID', + keyword: 'oneOf' // Doesn't match either option of null or uuid + }, + { + dataPath: '.MerchantInvoice[0].Item_Code', + keyword: 'pattern' + }, + { + dataPath: '.MerchantInvoice[0].Item_Description', + keyword: 'minLength' + }, + { + dataPath: '.MerchantInvoice[0].Item_VATCode', + keyword: 'pattern' + }, + { + dataPath: '.MerchantInvoice[0].Item_VATRate', + keyword: 'maximum' + }, + { + dataPath: '.MerchantInvoice[0].Item_NetAmount', + keyword: 'minimum' + }, + { + dataPath: '.MerchantInvoice[0].Item_NetAmount', + keyword: 'type' + }, + { + dataPath: '.MerchantInvoice[0].Item_NetAmount', + keyword: 'oneOf' + }, + { + dataPath: '.MerchantInvoice[0].Item_GrossAmount', + keyword: 'minimum' + }, + { + dataPath: '.MerchantInvoice[0].Item_GrossAmount', + keyword: 'type' + }, + { + dataPath: '.MerchantInvoice[0].Item_GrossAmount', + keyword: 'oneOf' + }, + { + dataPath: '.MerchantInvoice[0].Item_Quantity', + keyword: 'maximum' + }, + { + dataPath: '.MerchantInvoice[0].Line_TotalAmount', + keyword: 'maximum' + }, + { + dataPath: '.MerchantInvoice[0].Line_VATAmount', + keyword: 'maximum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [ + { + Item_ID: 'Not a UUID', + Item_Code: '~', + Item_Description: '', + Item_VATCode: '~', + Item_VATRate: 10001, + Item_NetAmount: -1, + Item_GrossAmount: -1, + Item_Quantity: 32001, + Line_TotalAmount: 25001, + Line_VATAmount: 25001 + } + ], + RequestAmount: 399, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with a merchantInvoice with more invalid param values', + valid: false, + expect: { + errors: [ + { + dataPath: '.MerchantInvoice[0].Item_ID', + keyword: 'type' // Doesn't match `null` + }, + { + dataPath: '.MerchantInvoice[0].Item_ID', + keyword: 'type' // Doesn't match string (uuid) + }, + { + dataPath: '.MerchantInvoice[0].Item_ID', + keyword: 'oneOf' // Doesn't match either option of null or uuid + }, + { + dataPath: '.MerchantInvoice[0].Item_Code', + keyword: 'maxLength' + }, + { + dataPath: '.MerchantInvoice[0].Item_Description', + keyword: 'maxLength' + }, + { + dataPath: '.MerchantInvoice[0].Item_VATCode', + keyword: 'maxLength' + }, + { + dataPath: '.MerchantInvoice[0].Item_VATRate', + keyword: 'minimum' + }, + { + dataPath: '.MerchantInvoice[0].Item_NetAmount', + keyword: 'maximum' + }, + { + dataPath: '.MerchantInvoice[0].Item_NetAmount', + keyword: 'type' + }, + { + dataPath: '.MerchantInvoice[0].Item_NetAmount', + keyword: 'oneOf' + }, + { + dataPath: '.MerchantInvoice[0].Item_GrossAmount', + keyword: 'maximum' + }, + { + dataPath: '.MerchantInvoice[0].Item_GrossAmount', + keyword: 'type' + }, + { + dataPath: '.MerchantInvoice[0].Item_GrossAmount', + keyword: 'oneOf' + }, + { + dataPath: '.MerchantInvoice[0].Item_Quantity', + keyword: 'minimum' + }, + { + dataPath: '.MerchantInvoice[0].Line_TotalAmount', + keyword: 'minimum' + }, + { + dataPath: '.MerchantInvoice[0].Line_VATAmount', + keyword: 'minimum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [ + { + Item_ID: 1234, + Item_Code: _.repeat('a', 51), + Item_Description: _.repeat('a', 151), + Item_VATCode: _.repeat('a', 51), + Item_VATRate: -1, + Item_NetAmount: 32001, + Item_GrossAmount: 32001, + Item_Quantity: 0, + Line_VATAmount: -1, + Line_TotalAmount: -1 + } + ], + RequestAmount: 399, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with a merchantInvoice with untrimmed param values', + valid: false, + expect: { + errors: [ + { + dataPath: '.MerchantInvoice[0].Item_Code', + keyword: 'ensureTrim' + }, + { + dataPath: '.MerchantInvoice[0].Item_Description', + keyword: 'ensureTrim' + }, + { + dataPath: '.MerchantInvoice[0].Item_VATCode', + keyword: 'ensureTrim' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '1AA1A', + MerchantInvoice: [ + { + Item_ID: VALID_UUID, + Item_Code: ' a ', + Item_Description: ' a', + Item_VATCode: 'a ', + Item_VATRate: 0, + Item_NetAmount: 0, + Item_GrossAmount: 0, + Item_Quantity: 1, + Line_VATAmount: 0, + Line_TotalAmount: 0 + } + ], + RequestAmount: 399, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + }, + { + name: 'with a PayCode with invalid param values', + valid: false, + expect: { + errors: [ + { + dataPath: '.PayCode', + keyword: 'maxLength' + }, + { + dataPath: '.PayCode', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PayCode: '123456789012Z', + MerchantComment: 'You were served today by Stuey.', + RequestAmount: 0, + RequestTip: 1, + AccountID: VALID_UUID, + Latitude: 0, + Longitude: 0 + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Payment Commands', TEST_SUITE); diff --git a/node_server/schemas/PostCodeLookup.json b/node_server/schemas/PostCodeLookup.json new file mode 100644 index 0000000..a4c08ea --- /dev/null +++ b/node_server/schemas/PostCodeLookup.json @@ -0,0 +1,24 @@ +{ + "$id": "PostcodeLookup", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "PostcodeLookup", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/utility_commands/post_code_lookup/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "PostCode" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "PostCode": { + "$ref": "defs/#/definitions/postcode" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/RedeemPayCode.json b/node_server/schemas/RedeemPayCode.json new file mode 100644 index 0000000..b9e89f6 --- /dev/null +++ b/node_server/schemas/RedeemPayCode.json @@ -0,0 +1,65 @@ +{ + "$id": "RedeemPayCode", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "RedeemPayCode", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/redeempaycode/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "PayCode", + "RequestAmount", + "AccountID", + "Latitude", + "Longitude" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "PayCode": { + "$ref": "defs/#/definitions/paycodeString" + }, + "MerchantInvoice": { + "type": "array", + "items": { + "$ref": "defs/#/definitions/merchantInvoiceItem" + }, + "minItems": 1 + }, + "MerchantComment": { + "allOf": [ + { + "minLength": 1, + "maxLength": 300, + "ensureTrim": true, + "example": "You were served today by Stuey." + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "RequestAmount": { + "$ref": "defs/#/definitions/positivePayment" + }, + "RequestTip": { + "description": "1 to request a tip from the customer. 0 or not present to not request a tip", + "type": "integer", + "enum": [0, 1] + }, + "AccountID": { + "$ref": "defs/#/definitions/uuid" + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/RefundTransaction.json b/node_server/schemas/RefundTransaction.json new file mode 100644 index 0000000..5ada1b9 --- /dev/null +++ b/node_server/schemas/RefundTransaction.json @@ -0,0 +1,36 @@ +{ + "$id": "RefundTransaction", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "RefundTransaction", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/payment_commands/refundtransaction/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "TransactionID", + "Latitude", + "Longitude", + "ClientKey" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "TransactionID": { + "$ref": "defs/#/definitions/uuid" + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + }, + "ClientKey": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Register1.json b/node_server/schemas/Register1.json new file mode 100644 index 0000000..565554b --- /dev/null +++ b/node_server/schemas/Register1.json @@ -0,0 +1,63 @@ +{ + "$id": "Register1", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register1", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register1/", + "type": "object", + "required": [ + "Method", + "ClientName", + "Password", + "DeviceNumber", + "OperatorName", + "DeviceUuid", + "DeviceHardware", + "DeviceSoftware" + ], + "additionalProperties": false, + "properties": { + "Method": { + "$ref": "defs/#/definitions/Method" + }, + "ClientName": { + "$ref": "defs/#/definitions/ClientName" + }, + "Password": { + "$ref": "defs/#/definitions/sha256" + }, + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "OperatorName": { + "$ref": "defs/#/definitions/OperatorName" + }, + "DeviceUuid": { + "$ref": "defs/#/definitions/DeviceUuid" + }, + "DeviceHardware": { + "allOf": [ + { + "minLength": 0, + "maxLength": 75 + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "DeviceSoftware": { + "allOf": [ + { + "minLength": 0, + "maxLength": 75 + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "Mode": { + "$ref": "defs/#/definitions/testMode" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Register2.json b/node_server/schemas/Register2.json new file mode 100644 index 0000000..eae1b7e --- /dev/null +++ b/node_server/schemas/Register2.json @@ -0,0 +1,32 @@ +{ + "$id": "Register2", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register2", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register2/", + "type": "object", + "required": [ + "DeviceNumber", + "DeviceToken", + "RegistrationToken" + ], + "additionalProperties": false, + "properties": { + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "RegistrationToken": { + "allOf": [ + { + "minLength": 6, + "maxLength": 6 + }, + { + "$ref": "defs/#/definitions/numeric" + } + ] + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Register3.json b/node_server/schemas/Register3.json new file mode 100644 index 0000000..b1ed476 --- /dev/null +++ b/node_server/schemas/Register3.json @@ -0,0 +1,32 @@ +{ + "$id": "Register3", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register3", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register3/", + "type": "object", + "required": [ + "ClientName", + "DeviceToken", + "Latitude", + "Longitude", + "DeviceAuthorisation" + ], + "additionalProperties": false, + "properties": { + "ClientName": { + "$ref": "defs/#/definitions/ClientName" + }, + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "Latitude": { + "$ref": "defs/#/definitions/latitude" + }, + "Longitude": { + "$ref": "defs/#/definitions/longitude" + }, + "DeviceAuthorisation": { + "$ref": "defs/#/definitions/sha256" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Register4.json b/node_server/schemas/Register4.json new file mode 100644 index 0000000..faf4ca7 --- /dev/null +++ b/node_server/schemas/Register4.json @@ -0,0 +1,24 @@ +{ + "$id": "Register4", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register4", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register4/", + "type": "object", + "required": [ + "DeviceNumber", + "DeviceUuid", + "DeviceToken" + ], + "additionalProperties": false, + "properties": { + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "DeviceUuid": { + "$ref": "defs/#/definitions/DeviceUuid" + }, + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Register6.json b/node_server/schemas/Register6.json new file mode 100644 index 0000000..229fbd2 --- /dev/null +++ b/node_server/schemas/Register6.json @@ -0,0 +1,24 @@ +{ + "$id": "Register6", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register6", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register6/", + "type": "object", + "required": [ + "ClientName", + "DeviceNumber", + "DeviceUuid" + ], + "additionalProperties": false, + "properties": { + "ClientName": { + "$ref": "defs/#/definitions/ClientName" + }, + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "DeviceUuid": { + "$ref": "defs/#/definitions/DeviceUuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Register7.json b/node_server/schemas/Register7.json new file mode 100644 index 0000000..81d2b98 --- /dev/null +++ b/node_server/schemas/Register7.json @@ -0,0 +1,9 @@ +{ + "$id": "Register7", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register7", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register7/", + "type": "object", + "additionalProperties": false, + "properties": {} +} \ No newline at end of file diff --git a/node_server/schemas/Register7.params.json b/node_server/schemas/Register7.params.json new file mode 100644 index 0000000..095a803 --- /dev/null +++ b/node_server/schemas/Register7.params.json @@ -0,0 +1,30 @@ +{ + "$id": "Register7.params", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register7 Params", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register7/", + "type": "object", + "required": [ + "Command", + "ClientName", + "DeviceNumber" + ], + "additionalProperties": false, + "properties": { + "Command": { + "type": "string", + "const": "Register7" + }, + "ClientName": { + "$ref": "defs/#/definitions/ClientName" + }, + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "Mode": { + "description": "Optional parameter omitted normally omitted. Use to kill the account post login. Note: this is not the same as most other params called Mode", + "type": "string", + "const": "ForceDelete" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/Register8.json b/node_server/schemas/Register8.json new file mode 100644 index 0000000..490b640 --- /dev/null +++ b/node_server/schemas/Register8.json @@ -0,0 +1,25 @@ +{ + "$id": "Register8", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Register8", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/register8/", + "type": "object", + "required": [ + "ClientName", + "DeviceNumber" + ], + "additionalProperties": false, + "properties": { + "ClientName": { + "$ref": "defs/#/definitions/ClientName" + }, + "DeviceNumber": { + "$ref": "defs/#/definitions/phoneNumber" + }, + "Mode": { + "description": "Optional parameter omitted normally omitted. Use to kill the account post login. Note: this is not the same as most other params called Mode", + "type": "string", + "const": "ForceDelete" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/RegistrationCommands.spec.js b/node_server/schemas/RegistrationCommands.spec.js new file mode 100644 index 0000000..ad851ea --- /dev/null +++ b/node_server/schemas/RegistrationCommands.spec.js @@ -0,0 +1,1124 @@ +/** + * @fileOverview Unit tests for the schemas for the Registration commands + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/} + */ +'use strict'; +var _ = require('lodash'); +var testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_SHA256 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +const VALID_UUID = '0123456789abcdef01234567'; +const VALID_NUMBER = '+4407700000000'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + AddDevice: [ + { + name: '', + valid: true, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: VALID_NUMBER, + DeviceUuid: _.repeat('a', 30), + DeviceHardware: 'Chai test', + DeviceSoftware: 'Chai test', + Latitude: 0, + Longitude: 0, + Mode: 'Test' + } + }, + { + name: 'no optional, and pseudo-optional (null, "", etc.) values', + valid: true, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: VALID_NUMBER, + DeviceUuid: _.repeat('a', 150), + DeviceHardware: '', + DeviceSoftware: '', + Latitude: null, + Longitude: null + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 8, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'Untrimmed device ID', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceUuid', + keyword: 'ensureTrim' + } + ] + }, + data: { + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: VALID_NUMBER, + DeviceUuid: ' not trimmed' + _.repeat('a', 30) + ' ', + DeviceHardware: 'Chai test', + DeviceSoftware: 'Chai test', + Latitude: 0, + Longitude: 0, + Mode: 'Test' + } + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientName', + keyword: 'pattern' + }, + { + dataPath: '.Password', + keyword: 'minLength' + }, + { + dataPath: '.Password', + keyword: 'pattern' + }, + { + dataPath: '.DeviceNumber', + keyword: 'pattern' + }, + { + dataPath: '.DeviceUuid', + keyword: 'minLength' + }, + { + dataPath: '.DeviceHardware', + keyword: 'maxLength' + }, + { + dataPath: '.DeviceSoftware', + keyword: 'maxLength' + }, + { + dataPath: '.Latitude', + keyword: 'maximum' + }, + { + dataPath: '.Longitude', + keyword: 'maximum' + }, + { + dataPath: '.Mode', + keyword: 'const' + } + ] + }, + data: { + ClientName: 'John Smith', + Password: 'Not a SHA 256', + DeviceNumber: '07700000000', + DeviceUuid: _.repeat('a', 29), + DeviceHardware: _.repeat('a', 76), + DeviceSoftware: _.repeat('a', 76), + Latitude: 91, + Longitude: 181, + Mode: 'test' + } + } + ], + DeleteDevice: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + Password: VALID_SHA256, + DeviceIndex: VALID_UUID + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 4, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'pattern' + }, + { + dataPath: '.Password', + keyword: 'minLength' + }, + { + dataPath: '.Password', + keyword: 'pattern' + }, + { + dataPath: '.DeviceIndex', + keyword: 'minLength' + }, + { + dataPath: '.DeviceIndex', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: 'Not a device token', + SessionToken: 'Not a session token', + Password: 'Not a sha 256', + DeviceIndex: 'Not a UUID' + } + } + ], + GetClientDetails: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: 'Not a device token', + SessionToken: 'Not a session token' + } + } + ], + ListDevices: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: 'Not a device token', + SessionToken: 'Not a session token' + } + } + ], + Register1: [ + { + name: '', + valid: true, + data: { + Method: 'Bridge', + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: VALID_NUMBER, + OperatorName: 'Comcarde', + DeviceUuid: _.repeat('a', 30), + DeviceHardware: 'Chai test', + DeviceSoftware: 'Chai test', + Mode: 'Test' + } + }, + { + name: 'no optional, and pseudo-optional (null, "", etc.) values', + valid: true, + data: { + Method: 'Bridge', + ClientName: 'a@example.com', + Password: VALID_SHA256, + DeviceNumber: VALID_NUMBER, + OperatorName: 'Comcarde', + DeviceUuid: _.repeat('a', 150), + DeviceHardware: '', + DeviceSoftware: '' + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 8, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.Method', + keyword: 'const' + }, + { + dataPath: '.ClientName', + keyword: 'pattern' + }, + { + dataPath: '.Password', + keyword: 'minLength' + }, + { + dataPath: '.Password', + keyword: 'pattern' + }, + { + dataPath: '.DeviceNumber', + keyword: 'pattern' + }, + { + dataPath: '.OperatorName', + keyword: 'const' + }, + { + dataPath: '.DeviceUuid', + keyword: 'minLength' + }, + { + dataPath: '.DeviceHardware', + keyword: 'maxLength' + }, + { + dataPath: '.DeviceSoftware', + keyword: 'maxLength' + }, + { + dataPath: '.Mode', + keyword: 'const' + } + ] + }, + data: { + Method: 'Google', + ClientName: 'John Smith', + Password: 'Not a SHA 256', + DeviceNumber: '07700000000', + OperatorName: 'A bank', + DeviceUuid: _.repeat('a', 29), + DeviceHardware: _.repeat('a', 76), + DeviceSoftware: _.repeat('a', 76), + Mode: 'Normal' + } + } + ], + Register2: [ + { + name: '', + valid: true, + data: { + DeviceNumber: VALID_NUMBER, + DeviceToken: VALID_DEVICE_TOKEN, + RegistrationToken: '123456' + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceNumber', + keyword: 'pattern' + }, + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.RegistrationToken', + keyword: 'pattern' + } + ] + }, + data: { + DeviceNumber: 'Not a device number', + DeviceToken: 'Not a device token', + RegistrationToken: '123abc' + } + } + ], + Register3: [ + { + name: '', + valid: true, + data: { + ClientName: 'a@example.com', + DeviceToken: VALID_DEVICE_TOKEN, + Longitude: 0, + Latitude: 0, + DeviceAuthorisation: VALID_SHA256 + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 5, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientName', + keyword: 'maxLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.Latitude', + keyword: 'maximum' + }, + { + dataPath: '.Longitude', + keyword: 'minimum' + }, + { + dataPath: '.DeviceAuthorisation', + keyword: 'minLength' + }, + { + dataPath: '.DeviceAuthorisation', + keyword: 'pattern' + } + ] + }, + data: { + ClientName: _.repeat('a', 53) + '@example.com', + DeviceToken: 'Not a device token', + Longitude: -181, + Latitude: 181, + DeviceAuthorisation: 'Not a sha 256' + } + } + ], + Register4: [ + { + name: '', + valid: true, + data: { + DeviceNumber: VALID_NUMBER, + DeviceUuid: _.repeat('a', 150), + DeviceToken: VALID_DEVICE_TOKEN + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceNumber', + keyword: 'type' + }, + { + dataPath: '.DeviceUuid', + keyword: 'maxLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'minLength' + } + ] + }, + data: { + DeviceNumber: 447700000000, + DeviceUuid: _.repeat('a', 151), + DeviceToken: '' + } + } + ], + Register6: [ + { + name: '', + valid: true, + data: { + ClientName: 'a@example.com', + DeviceNumber: VALID_NUMBER, + DeviceUuid: _.repeat('a', 150) + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientName', + keyword: 'minLength' + }, + { + dataPath: '.ClientName', + keyword: 'pattern' + }, + { + dataPath: '.DeviceNumber', + keyword: 'pattern' + }, + { + dataPath: '.DeviceUuid', + keyword: 'minLength' + } + ] + }, + data: { + ClientName: '', + DeviceNumber: '447700000000', + DeviceUuid: _.repeat('a', 29) + } + } + ], + 'Register7': [ + { + name: '', + valid: true, + data: {} + }, + { + name: 'unexpected param', + valid: false, + expect: { + missingRequiredCount: 0, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + } + ], + 'Register7.params': [ + { + name: '', + valid: true, + data: { + Command: 'Register7', + ClientName: 'a@example.com', + DeviceNumber: VALID_NUMBER, + Mode: 'ForceDelete' + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.Command', + keyword: 'const' + }, + { + dataPath: '.ClientName', + keyword: 'minLength' + }, + { + dataPath: '.ClientName', + keyword: 'pattern' + }, + { + dataPath: '.DeviceNumber', + keyword: 'pattern' + }, + { + dataPath: '.Mode', + keyword: 'const' + } + ] + }, + data: { + Command: 'Not register 7!', + ClientName: '', + DeviceNumber: '447700000000', + Mode: 'Test' + } + } + ], + Register8: [ + { + name: '', + valid: true, + data: { + ClientName: 'a@example.com', + DeviceNumber: VALID_NUMBER, + Mode: 'ForceDelete' + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 2, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.ClientName', + keyword: 'minLength' + }, + { + dataPath: '.ClientName', + keyword: 'pattern' + }, + { + dataPath: '.DeviceNumber', + keyword: 'pattern' + }, + { + dataPath: '.Mode', + keyword: 'const' + } + ] + }, + data: { + ClientName: '', + DeviceNumber: '447700000000', + Mode: 'Test' + } + } + ], + ResumeDevice: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + Password: VALID_SHA256, + DeviceIndex: VALID_UUID + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 4, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'pattern' + }, + { + dataPath: '.Password', + keyword: 'minLength' + }, + { + dataPath: '.Password', + keyword: 'pattern' + }, + { + dataPath: '.DeviceIndex', + keyword: 'minLength' + }, + { + dataPath: '.DeviceIndex', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: 'Not a device token', + SessionToken: 'Not a session token', + Password: 'Not a sha 256', + DeviceIndex: 'Not a UUID' + } + } + ], + SetClientDetails: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + Title: 'Mr', + FirstName: 'John', + MiddleNames: 'James Michael', + LastName: 'Smith', + DateOfBirth: '1970-01-01', + ResidentialAddressID: VALID_UUID, + Gender: 'M' + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 8, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'pattern' + }, + { + dataPath: '.Title', + keyword: 'minLength' + }, + { + dataPath: '.FirstName', + keyword: 'maxLength' + }, + { + dataPath: '.MiddleNames', + keyword: 'maxLength' + }, + { + dataPath: '.LastName', + keyword: 'minLength' + }, + { + dataPath: '.DateOfBirth', + keyword: 'format' + }, + { + dataPath: '.ResidentialAddressID', + keyword: 'minLength' + }, + { + dataPath: '.ResidentialAddressID', + keyword: 'pattern' + }, + { + dataPath: '.Gender', + keyword: 'enum' + } + ] + }, + data: { + DeviceToken: 'Not a device token', + SessionToken: 'Not a session token', + Title: 'M', + FirstName: _.repeat('a', 51), + MiddleNames: _.repeat('a', 51), + LastName: _.repeat('a', 1), + DateOfBirth: '1970-13-32', + ResidentialAddressID: 'NOT A UUID', + Gender: 'N' + } + }, + { + name: 'different mistakes in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.Title', + keyword: 'maxLength' + }, + { + dataPath: '.FirstName', + keyword: 'minLength' + }, + { + dataPath: '.LastName', + keyword: 'maxLength' + }, + { + dataPath: '.DateOfBirth', + keyword: 'pattern' + }, + { + dataPath: '.DateOfBirth', + keyword: 'format' + }, + { + dataPath: '.ResidentialAddressID', + keyword: 'maxLength' + }, + { + dataPath: '.ResidentialAddressID', + keyword: 'pattern' + }, + { + dataPath: '.Gender', + keyword: 'enum' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + Title: _.repeat('M', 21), + FirstName: _.repeat('a', 1), + MiddleNames: '', + LastName: _.repeat('a', 51), + DateOfBirth: '1st Jan, 1970', + ResidentialAddressID: _.repeat('a', 25), + Gender: 'MM' + } + }, + { + name: 'untrimmed name params', + valid: false, + expect: { + errors: [ + { + dataPath: '.Title', + keyword: 'ensureTrim' + }, + { + dataPath: '.FirstName', + keyword: 'ensureTrim' + }, + { + dataPath: '.MiddleNames', + keyword: 'ensureTrim' + }, + { + dataPath: '.LastName', + keyword: 'ensureTrim' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + Title: ' Mr ', + FirstName: ' Un', + MiddleNames: 'Trimmed ', + LastName: ' Example ', + DateOfBirth: '1970-01-01', + ResidentialAddressID: VALID_UUID, + Gender: 'F' + } + } + ], + SetDeviceName: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + DeviceName: 'A phone', + DeviceIndex: VALID_UUID + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 4, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceName', + keyword: 'ensureTrim' + }, + { + dataPath: '.DeviceIndex', + keyword: 'minLength' + }, + { + dataPath: '.DeviceIndex', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + DeviceName: ' Oops ', + DeviceIndex: 'Not a UUID' + } + }, + { + name: 'name too short', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceName', + keyword: 'minLength' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + DeviceName: 'A', + DeviceIndex: VALID_UUID + } + }, + { + name: 'name too long', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceName', + keyword: 'maxLength' + } + ] + }, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + DeviceName: _.repeat('a', 76), + DeviceIndex: VALID_UUID + } + } + ], + SuspendDevice: [ + { + name: '', + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + DeviceIndex: VALID_UUID + } + }, + { + name: 'less than required params', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'mistake in every param', + valid: false, + expect: { + errors: [ + { + dataPath: '.DeviceToken', + keyword: 'minLength' + }, + { + dataPath: '.DeviceToken', + keyword: 'pattern' + }, + { + dataPath: '.SessionToken', + keyword: 'minLength' + }, + { + dataPath: '.SessionToken', + keyword: 'pattern' + }, + { + dataPath: '.DeviceIndex', + keyword: 'minLength' + }, + { + dataPath: '.DeviceIndex', + keyword: 'pattern' + } + ] + }, + data: { + DeviceToken: 'Not a device token', + SessionToken: 'Not a session token', + DeviceIndex: 'Not a UUID' + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: Registration Commands', TEST_SUITE); diff --git a/node_server/schemas/RejectInvoice.json b/node_server/schemas/RejectInvoice.json new file mode 100644 index 0000000..4c499de --- /dev/null +++ b/node_server/schemas/RejectInvoice.json @@ -0,0 +1,37 @@ +{ + "$id": "RejectInvoice", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "RejectInvoice", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/invoice_commands/reject_invoice/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "InvoiceID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "InvoiceID": { + "$ref": "defs/#/definitions/uuid" + }, + "Comment": { + "allOf": [ + { + "minLength": 1, + "maxLength": 300, + "ensureTrim": true, + "example": "Wrong price" + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ReportImage.json b/node_server/schemas/ReportImage.json new file mode 100644 index 0000000..7216808 --- /dev/null +++ b/node_server/schemas/ReportImage.json @@ -0,0 +1,24 @@ +{ + "$id": "ReportImage", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ReportImage", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/image_commands/reportimage/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "ImageRef" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "ImageRef": { + "$ref": "defs/#/definitions/imageRef" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/ResumeDevice.json b/node_server/schemas/ResumeDevice.json new file mode 100644 index 0000000..642a0cb --- /dev/null +++ b/node_server/schemas/ResumeDevice.json @@ -0,0 +1,28 @@ +{ + "$id": "ResumeDevice", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "ResumeDevice", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/resumedevice/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "Password", + "DeviceIndex" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "Password": { + "$ref": "defs/#/definitions/sha256" + }, + "DeviceIndex": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/RotateHMAC.json b/node_server/schemas/RotateHMAC.json new file mode 100644 index 0000000..c39ba44 --- /dev/null +++ b/node_server/schemas/RotateHMAC.json @@ -0,0 +1,20 @@ +{ + "$id": "RotateHMAC", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "RotateHMAC Command", + "description": "Schema for RotateHMAC command", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/SetAccountAddress.json b/node_server/schemas/SetAccountAddress.json new file mode 100644 index 0000000..06c4ece --- /dev/null +++ b/node_server/schemas/SetAccountAddress.json @@ -0,0 +1,28 @@ +{ + "$id": "SetAccountAddress", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "SetAccountAddress", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/setaccountaddress/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "AccountID", + "AddressID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "AccountID": { + "$ref": "defs/#/definitions/uuid" + }, + "AddressID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/SetClientDetails.json b/node_server/schemas/SetClientDetails.json new file mode 100644 index 0000000..6a43298 --- /dev/null +++ b/node_server/schemas/SetClientDetails.json @@ -0,0 +1,88 @@ +{ + "$id": "SetClientDetails", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "SetClientDetails", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/setclientdetails/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "Title", + "FirstName", + "LastName", + "DateOfBirth", + "ResidentialAddressID", + "Gender" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "Title": { + "allOf": [ + { + "minLength": 2, + "maxLength": 20, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/alphaSpace" + } + ] + }, + "FirstName": { + "allOf": [ + { + "minLength": 2, + "maxLength": 50, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/alphaSpace" + } + ] + }, + "MiddleNames": { + "allOf": [ + { + "minLength": 0, + "maxLength": 50, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/alphaSpace" + } + ] + }, + "LastName": { + "allOf": [ + { + "minLength": 2, + "maxLength": 50, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/alphaSpace" + } + ] + }, + "DateOfBirth": { + "$ref": "defs/#/definitions/date" + }, + "ResidentialAddressID": { + "$ref": "defs/#/definitions/uuid" + }, + "Gender": { + "description": "The gender as required by the ID verification/AML service.", + "type": "string", + "enum": [ + "M", + "F" + ] + } + } +} \ No newline at end of file diff --git a/node_server/schemas/SetDefaultAccount.json b/node_server/schemas/SetDefaultAccount.json new file mode 100644 index 0000000..837ef63 --- /dev/null +++ b/node_server/schemas/SetDefaultAccount.json @@ -0,0 +1,24 @@ +{ + "$id": "SetDefaultAccount", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "SetDefaultAccount", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/account_commands/setdefaultaccount/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "AccountID" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "AccountID": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/SetDeviceName.json b/node_server/schemas/SetDeviceName.json new file mode 100644 index 0000000..e100446 --- /dev/null +++ b/node_server/schemas/SetDeviceName.json @@ -0,0 +1,37 @@ +{ + "$id": "SetDeviceName", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "SetDeviceName", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/setdevicename/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "DeviceName", + "DeviceIndex" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "DeviceName": { + "allOf": [ + { + "minLength": 2, + "maxLength": 75, + "ensureTrim": true + }, + { + "$ref": "defs/#/definitions/generalTextSpace" + } + ] + }, + "DeviceIndex": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/SuspendDevice.json b/node_server/schemas/SuspendDevice.json new file mode 100644 index 0000000..ffc5cbb --- /dev/null +++ b/node_server/schemas/SuspendDevice.json @@ -0,0 +1,24 @@ +{ + "$id": "SuspendDevice", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "SuspendDevice", + "description": "See http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/suspenddevice/", + "type": "object", + "required": [ + "DeviceToken", + "SessionToken", + "DeviceIndex" + ], + "additionalProperties": false, + "properties": { + "DeviceToken": { + "$ref": "defs/#/definitions/DeviceToken" + }, + "SessionToken": { + "$ref": "defs/#/definitions/SessionToken" + }, + "DeviceIndex": { + "$ref": "defs/#/definitions/uuid" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/customKeywords/ensuretrim.js b/node_server/schemas/customKeywords/ensuretrim.js new file mode 100644 index 0000000..43ed21b --- /dev/null +++ b/node_server/schemas/customKeywords/ensuretrim.js @@ -0,0 +1,35 @@ +/** + * @fileOverview Custom keyword for ajv + * + * This defines a custom keyword for ajv + */ +'use strict'; + +module.exports = { + keyword: 'ensureTrim', + definition: { + errors: false, + async: false, + metaSchema: { + type: 'boolean' + }, + compile: doValidate + } +}; + +/** + * Function to validate that a string passed in has been trimed (i.e. has no + * leading or trailing spaces). + * + * @param {boolean} schema - true = ensure trim, false = ignore trim status + * @param {Object} parentSchema - The schema + * + * @returns {Function} - The function to do the compare at runtime + */ +function doValidate(schema, parentSchema) { + var checkTrim = schema; + + return function(data) { + return !checkTrim || data === data.trim(); + }; +} diff --git a/node_server/schemas/customKeywords/ensuretrim.spec.js b/node_server/schemas/customKeywords/ensuretrim.spec.js new file mode 100644 index 0000000..ed634b8 --- /dev/null +++ b/node_server/schemas/customKeywords/ensuretrim.spec.js @@ -0,0 +1,144 @@ +/** + * @fileOverview Unit tests for the ensureTrime custom keyword + */ +/* globals describe, beforeEach, it */ +'use strict'; + +var _ = require('lodash'); +var validator = require('../validator.js'); +var Ajv = require('ajv'); +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); +var expect = chai.expect; + +chai.use(chaiAsPromised); + +/** + * Set the path prefix to a value that will work for these tests + */ +const SCHEMA_ROOT = '../schemas'; + +/** + * A const base schema for our testing + */ +const BASE_SCHEMA = { + $id: 'TestEnsureTrim', + $schema: 'http://json-schema.org/draft-06/schema#', + title: 'Test ensureTrim custom keyword', + type: 'object', + required: ['test'], + properties: { + test: { + type: 'string', + ensureTrim: true + } + } +}; + +describe('Schemas: ensureTrim custom keyword', function() { + /** + * Local copy of the ajv validator so we can insert our own test schemas + */ + var ajv; + + /** + * Initialise the validator with the right schema. + * Also set our own injected constructor so we can keep a copy of the validtor + */ + beforeEach(function() { + validator.setDIValidatorConstructor( + function(options) { + ajv = new Ajv(options); + return ajv; + } + ); + validator.initialise([], true, SCHEMA_ROOT); + }); + + describe('with ensureTrim = true', function() { + /** + * Add the test schema with 8 decimal places + */ + beforeEach(function() { + ajv.addSchema(BASE_SCHEMA); + }); + + /** + * Test the keyword works correctly + */ + it('should accept an empty string', function() { + return expect( + validator.validate('TestEnsureTrim', {test: ''}) + ).to.eventually.be.fulfilled; + }); + + it('should accept a trimmed string', function() { + return expect( + validator.validate('TestEnsureTrim', {test: 'Test String'}) + ).to.eventually.be.fulfilled; + }); + + it('should reject a string with space at the start', function() { + return expect( + validator.validate('TestEnsureTrim', {test: ' Test String'}) + ).to.eventually.be.rejected; + }); + + it('should reject a string with space at the end', function() { + return expect( + validator.validate('TestEnsureTrim', {test: 'Test String '}) + ).to.eventually.be.rejected; + }); + + it('should reject a string with space at both ends', function() { + return expect( + validator.validate('TestEnsureTrim', {test: ' Test String '}) + ).to.eventually.be.rejected; + }); + }); + + describe('with ensureTrim = false', function() { + /** + * Add the test schema with 8 decimal places + */ + beforeEach(function() { + var schema = _.clone(BASE_SCHEMA); + schema.properties.test.ensureTrim = false; + ajv.addSchema(schema); + }); + + /** + * Test the keyword works correctly + */ + it('should accept an empty string', function() { + return expect( + validator.validate('TestEnsureTrim', {test: ''}) + ).to.eventually.be.fulfilled; + }); + + it('should accept a trimmed string', function() { + return expect( + validator.validate('TestEnsureTrim', {test: 'Test String'}) + ).to.eventually.be.fulfilled; + }); + + it('should accept a string with space at the start', function() { + return expect( + validator.validate('TestEnsureTrim', {test: ' Test String'}) + ).to.eventually.be.fulfilled; + }); + + it('should accept a string with space at the end', function() { + return expect( + validator.validate('TestEnsureTrim', {test: 'Test String '}) + ).to.eventually.be.fulfilled; + }); + + it('should accept a string with space at both ends', function() { + return expect( + validator.validate('TestEnsureTrim', {test: ' Test String '}) + ).to.eventually.be.fulfilled; + }); + }); + +}); diff --git a/node_server/schemas/customKeywords/maxdp.js b/node_server/schemas/customKeywords/maxdp.js new file mode 100644 index 0000000..074d8b9 --- /dev/null +++ b/node_server/schemas/customKeywords/maxdp.js @@ -0,0 +1,42 @@ +/** + * @fileOverview Custom keyword for ajv + * + * This defines a custom keyword for ajv + */ +'use strict'; + +module.exports = { + keyword: 'maxDecimalPlaces', + definition: { + errors: false, + async: false, + metaSchema: { + type: 'number', + minimum: 0 + }, + compile: doValidate + } +}; + +/** + * Function to validate that a number passed in has less than the given + * number of places. + * + * @param {any} schema - The number of places + * @param {Object} parentSchema - The schema + * + * @returns {Function} - The function to do the compare at runtime + */ +function doValidate(schema, parentSchema) { + var numPlaces = schema; + + return function(data) { + var tempString = '' + data; + var pieces = tempString.split('.'); + if (pieces.length < 2) { + return true; + } else { + return pieces[1].length <= numPlaces; + } + }; +} diff --git a/node_server/schemas/customKeywords/maxdp.spec.js b/node_server/schemas/customKeywords/maxdp.spec.js new file mode 100644 index 0000000..b89ba94 --- /dev/null +++ b/node_server/schemas/customKeywords/maxdp.spec.js @@ -0,0 +1,158 @@ +/** + * @fileOverview Unit tests for the maxDecimalPlaces custom keyword + */ +/* globals describe, beforeEach, it */ +'use strict'; + +var _ = require('lodash'); +var validator = require('../validator.js'); +var Ajv = require('ajv'); +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); +var expect = chai.expect; + +chai.use(chaiAsPromised); + +/** + * Set the path prefix to a value that will work for these tests + */ +const SCHEMA_ROOT = '../schemas'; + +/** + * A const base schema for our testing + */ +const BASE_SCHEMA = { + $id: 'TestMaxDP', + $schema: 'http://json-schema.org/draft-06/schema#', + title: 'Test maxDecimalPlaces custom keyword', + type: 'object', + properties: { + test: { + type: 'number', + maxDecimalPlaces: 8 + } + } +}; + +describe('Schemas: maxDecimalPlaces custom keyword', function() { + /** + * Local copy of the ajv validator so we can insert our own test schemas + */ + var ajv; + + /** + * Initialise the validator with the right schema. + * Also set our own injected constructor so we can keep a copy of the validtor + */ + beforeEach(function() { + validator.setDIValidatorConstructor( + function(options) { + ajv = new Ajv(options); + return ajv; + } + ); + validator.initialise([], true, SCHEMA_ROOT); + }); + + describe('with maxDecimalPlaces = 8', function() { + + /** + * Add the test schema with 8 decimal places + */ + beforeEach(function() { + ajv.addSchema(BASE_SCHEMA); + }); + + /** + * Test the keyword works correctly + */ + it('should reject 0 decimal places as a string', function() { + return expect( + validator.validate('TestMaxDP', {test: '0'}) + ).to.eventually.be.rejected; + }); + + it('should accept 0 decimal places as a number', function() { + return expect( + validator.validate('TestMaxDP', {test: 0}) + ).to.eventually.be.fulfilled; + }); + + it('should accept exactly 8 decimal places as a string', function() { + return expect( + validator.validate('TestMaxDP', {test: '0.12345678'}) + ).to.eventually.be.rejected; + }); + + it('should accept exactly 8 decimal places as a number', function() { + return expect( + validator.validate('TestMaxDP', {test: 0.12345678}) + ).to.eventually.be.fulfilled; + }); + + it('should reject >8 decimal places as a string', function() { + return expect( + validator.validate('TestMaxDP', {test: '0.123456789'}) + ).to.eventually.be.rejected; + }); + + it('should reject >8 decimal places as a number', function() { + return expect( + validator.validate('TestMaxDP', {test: 0.123456789}) + ).to.eventually.be.rejected; + }); + }); + + describe('with maxDecimalPlaces = 4', function() { + + /** + * Add the test schema with 8 decimal places + */ + beforeEach(function() { + var schema4DP = _.clone(BASE_SCHEMA); + schema4DP.$id = 'Test4DP'; + schema4DP.properties.test.maxDecimalPlaces = 4; + ajv.addSchema(schema4DP); + }); + + /** + * Test the keyword works correctly + */ + it('should reject 0 decimal places as a string', function() { + return expect( + validator.validate('Test4DP', {test: '0'}) + ).to.eventually.be.rejected; + }); + + it('should accept 0 decimal places as a number', function() { + return expect( + validator.validate('Test4DP', {test: 0}) + ).to.eventually.be.fulfilled; + }); + + it('should reject exactly 4 decimal places as a string', function() { + return expect( + validator.validate('Test4DP', {test: '0.1234'}) + ).to.eventually.be.rejected; + }); + + it('should accept exactly 4 decimal places as a number', function() { + return expect( + validator.validate('Test4DP', {test: 0.1234}) + ).to.eventually.be.fulfilled; + }); + + it('should reject >4 decimal places as a string', function() { + return expect( + validator.validate('Test4DP', {test: '0.12345'}) + ).to.eventually.be.rejected; + }); + + it('should reject >4 decimal places as a number', function() { + return expect( + validator.validate('Test4DP', {test: 0.12345}) + ).to.eventually.be.rejected; + }); + }); + +}); diff --git a/node_server/schemas/defaultCommandOnly.params.json b/node_server/schemas/defaultCommandOnly.params.json new file mode 100644 index 0000000..82d54df --- /dev/null +++ b/node_server/schemas/defaultCommandOnly.params.json @@ -0,0 +1,16 @@ +{ + "$id": "defaultCommandOnly", + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "defaultCommandOnly Params", + "description": "Default parameters validator that only allows the command name.", + "type": "object", + "required": [ + "Command" + ], + "additionalProperties": false, + "properties": { + "Command": { + "$ref": "defs/#/definitions/fullAlphaNumeric" + } + } +} \ No newline at end of file diff --git a/node_server/schemas/defaults.spec.js b/node_server/schemas/defaults.spec.js new file mode 100644 index 0000000..5e9467a --- /dev/null +++ b/node_server/schemas/defaults.spec.js @@ -0,0 +1,75 @@ +/** + * @fileOverview Unit tests for default schemas + * + */ +var _ = require('lodash'); +var testHelper = require('./testHelpers.js'); + +/** + * Test data + */ +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_SHA256 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +const VALID_UUID = '0123456789abcdef01234567'; +const VALID_NUMBER = '+4407700000000'; + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + 'defaultCommandOnly.params': [ + { + name: '', + valid: true, + data: { + Command: 'Command123' + } + }, + { + name: 'missing command + unexpected extra param', + valid: false, + expect: { + missingRequiredCount: 1, + additionalPropsCount: 1 + }, + data: {invalidAdditionalProp: 'a'} + }, + { + name: 'invalid command format: (no spaces)', + valid: false, + expect: { + errors: [ + { + dataPath: '.Command', + keyword: 'pattern' + } + ] + }, + data: { + Command: 'Space Not Allowed1' + } + }, + { + name: 'invalid command format: (no special chars)', + valid: false, + expect: { + errors: [ + { + dataPath: '.Command', + keyword: 'pattern' + } + ] + }, + data: { + Command: 'Escaped%20Space%20Not%20Allowed1' + } + } + ] +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: default schemas', TEST_SUITE); diff --git a/node_server/schemas/definitions.json b/node_server/schemas/definitions.json new file mode 100644 index 0000000..08064d0 --- /dev/null +++ b/node_server/schemas/definitions.json @@ -0,0 +1,475 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Definitions for the Bridge App API", + "description": "Definitions of types useful for defining the bodies of API calls", + "$id": "defs/", + "definitions": { + "paymentMax": { + "description": "The maximum amount that can be paid in a single transaction", + "type": "integer", + "maximum": 25000 + }, + "quantityLimits": { + "description": "The maximim quantity of items in a transaction MerchantInvoice line item", + "type": "integer", + "minimum": 1, + "maximum": 32000 + }, + "email": { + "description": "basic email address parsing. See http://www.regular-expressions.info/email.html", + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "x-invalid-pattern": "[^a-zA-Z0-9.%+-.@]", + "minLength": 7, + "maxLength": 64, + "example": "admin@example.com" + }, + "uuid": { + "description": "reference to another object in the database", + "type": "string", + "pattern": "^([a-f0-9]{24})$", + "x-invalid-pattern": "[^a-f0-9]", + "minLength": 24, + "maxLength": 24, + "example": "12a345b67c8901234d567e89" + }, + "uuidNullable": { + "description": "reference to another object in the database or `null`", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/uuid" + } + ] + }, + "hex256": { + "description": "A 256bit hex value", + "type": "string", + "pattern": "^([a-f0-9]{64})$", + "x-invalid-pattern": "[^a-f0-9]", + "minLength": 64, + "maxLength": 64, + "example": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "generalText": { + "description": "General text format + special chars", + "type": "string", + "pattern": "^([A-Za-z0-9'[\\]()@?!\\-/.,_&*:;+=]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9'[\\]()@?!\\-/.,_&*:;+=]", + "example": "SomeTextWithoutSpacesButWith'&','*',etc." + }, + "generalTextSpace": { + "description": "General text format + special chars + space", + "type": "string", + "pattern": "^([A-Za-z0-9'[\\]()@?!\\-/.,_&*:;+= ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9'[\\]()@?!\\-/.,_&*:;+= ]", + "example": "Some Text With Spaces plus '&', '*', etc." + }, + "generalTextSpaceNullable": { + "description": "General text format + special chars + space or `null`", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/generalTextSpace" + } + ] + }, + "fullAlphaNumeric": { + "description": "Text with only ASCII letters + numbers", + "type": "string", + "pattern": "^([A-Za-z0-9]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9]", + "example": "SomeTextWithoutSpacesOrSpecialCharsButWith0123456789" + }, + "fullAlphaNumericDashSpace": { + "description": "Text with only ASCII letters + numbers + dash", + "type": "string", + "pattern": "^([A-Za-z0-9\\- ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9\\- ]", + "example": "Some text with spaces plus 0123456789 And -" + }, + "fullAlphaNumericDashSpaceNullable": { + "description": "Text with only ASCII letters + numbers + dash", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/fullAlphaNumberDashSpace" + } + ] + }, + "alpha": { + "description": "Text with only ASCII letters", + "type": "string", + "pattern": "^([A-Za-z]*)$", + "x-invalid-pattern": "[^A-Za-z]", + "example": "SomeTextWithOnlyAsciiLetters" + }, + "alphaSpace": { + "description": "Text with only ASCII letters and space", + "type": "string", + "pattern": "^([A-Za-z ]*)$", + "x-invalid-pattern": "[^A-Za-z ]", + "example": "Some Text With Only Ascii Letters plus space" + }, + "alphaSpaceNullable": { + "description": "Optional text with only ASCII letters and space", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/alphaSpace" + } + ] + }, + "alphaDashSpace": { + "description": "Text with only ASCII letters, dash, and space", + "type": "string", + "pattern": "^([A-Za-z\\- ]*)$", + "x-invalid-pattern": "[^A-Za-z\\- ]", + "example": "Some Text With Only Ascii Letters plus space plus -" + }, + "paycodeString": { + "description": "Paycode string. 0-9 + A-Y except IOQ which could be confusing", + "type": "string", + "pattern": "^([0-9ABCDEFGHJKLMNPRSTUVWXY]*)$", + "minLength": 5, + "maxLength": 12, + "x-invalid-pattern": "[^0-9ABCDEFGHJKLMNPRSTUVWXY]", + "example": "A1A1A" + }, + "lowerCaseHex": { + "description": "Lower case, hexadecimal string (for hashes etc.)", + "type": "string", + "pattern": "^([a-f0-9]*)$", + "x-invalid-pattern": "[^a-f0-9]", + "example": "f0a1" + }, + "numeric": { + "description": "Numbers only, but sent as a string", + "type": "string", + "pattern": "^([0-9]*)$", + "x-invalid-pattern": "[^0-9]", + "example": "123" + }, + "version": { + "description": "Version number formatting. At least major.minor with optional further levels", + "type": "string", + "pattern": "^\\d+\\.\\d+(?:\\.\\d+)*$", + "maxLength": 20, + "x-invalid-pattern": "[^0-9.]" + }, + "sha256": { + "description": "A SHA-256 value.", + "allOf": [ + { + "minLength": 64, + "maxLength": 64 + }, + { + "$ref": "#/definitions/lowerCaseHex" + } + ] + }, + "longitude": { + "description": "Longitude Coordinate representing east/west location (or null for no location)", + "allOf": [ + { + "oneOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + { + "maximum": 180, + "minimum": -180, + "maxDecimalPlaces": 8 + } + ] + }, + "latitude": { + "description": "Latitude Coordinate representing north/south location (or null for no location)", + "allOf": [ + { + "oneOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + { + "maximum": 90, + "minimum": -90, + "maxDecimalPlaces": 8 + } + ] + }, + "postcode": { + "description": "A UK postcode", + "type": "string", + "pattern": "^[A-Z]{1,2}\\d{1,2}[A-Z]? ?\\d[A-Z]{2}$" + }, + "DeviceToken": { + "description": "As supplied by registration process Register1", + "allOf": [ + { + "minLength": 42, + "maxLength": 42 + }, + { + "$ref": "#/definitions/fullAlphaNumeric" + } + ] + }, + "SessionToken": { + "description": "As supplied by login process Login1", + "allOf": [ + { + "minLength": 42, + "maxLength": 42 + }, + { + "$ref": "#/definitions/fullAlphaNumeric" + } + ] + }, + "DeviceUuid": { + "description": "Unique and hidden identifier for the device", + "allOf": [ + { + "minLength": 30, + "maxLength": 150, + "ensureTrim": true + }, + { + "$ref": "#/definitions/generalTextSpace" + } + ] + }, + "ClientName": { + "$ref": "#/definitions/email" + }, + "Method": { + "description": "The sign up method.", + "type": "string", + "const": "Bridge" + }, + "OperatorName": { + "description": "The name of the account operator.", + "type": "string", + "const": "Comcarde" + }, + "phoneNumber": { + "description": "Phone number in international format (with no spaces): +44123...", + "type": "string", + "pattern": "^\\+44([0-9]*)$", + "minLength": 8, + "maxLength": 35, + "example": "+447700900000" + }, + "timeStamp": { + "description": "Date-time in ISO8601 format, in UTC including 3dp of milliseconds", + "type": "string", + "pattern": "^[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9].[0-9]{3}Z$", + "format": "date-time" + }, + "date": { + "description": "Date only (no time) in ISO8601 format", + "type": "string", + "format": "date", + "pattern": "^[0-9]{4}-[0-1][0-9]-[0-3][0-9]" + }, + "cardPAN": { + "description": "Credit or debit card PAN with spaces removed", + "allOf": [ + { + "minLength": 8, + "maxLength": 19 + }, + { + "$ref": "#/definitions/numeric" + } + ] + }, + "imageType": { + "description": "Define which image image type is being updated.", + "type": "string", + "enum": [ + "Selfie", + "CompanyLogo0" + ] + }, + "cardDate": { + "description": "A valid from or expiry date on a card - MM-YY format", + "type": "string", + "pattern": "^(?:0[1-9]|1[0-2])-[0-9][0-9]$" + }, + "base64Image": { + "description": "A base-64 encoded image file", + "type": "string", + "maxLength": 50000, + "minLength": 4, + "pattern": "^[A-Za-z0-9\\/+]+(={0,2})$" + }, + "fileType": { + "description": "The type of image file (PNG, JPG, or JPEG)", + "type": "string", + "enum": [ + "PNG", + "JPG", + "JPEG" + ] + }, + "imageRef": { + "description": "Reference id for an image", + "oneOf": [ + { + "allOf": [ + { + "minLength": 24, + "maxLength": 24 + }, + { + "$ref": "#/definitions/lowerCaseHex" + } + ] + }, + { + "type": "string", + "enum": [ + "defaultSelfie", + "defaultCompanyLogo0" + ] + } + ] + }, + "tipAmount": { + "description": "A tip amount optionally added to a transaction", + "example": "0", + "type": "integer", + "minimum": 0, + "maximum": 5000 + }, + "testMode": { + "description": "Use 'Test' to prevent the SMS and email from being sent.", + "type": "string", + "const": "Test" + }, + "positivePayment": { + "description": "A payment amount that must be positive", + "allOf": [ + { + "$ref": "#/definitions/paymentMax" + }, + { + "minimum": 0 + } + ] + }, + "positivePaymentNullable": { + "description": "A payment amount that must be positive or NULL", + "oneOf": [ + { + "$ref": "#/definitions/positivePayment" + }, + { + "type": "null" + } + ] + }, + "merchantInvoiceItem": { + "description": "A line item in the MerchantInvoice field of a transaction", + "type": "object", + "required": [ + "Item_ID", + "Item_Code", + "Item_Description", + "Item_VATCode", + "Item_VATRate", + "Item_NetAmount", + "Item_GrossAmount", + "Item_Quantity", + "Line_TotalAmount", + "Line_VATAmount" + ], + "additionalProperties": false, + "properties": { + "Item_ID": { + "$ref": "#/definitions/uuidNullable" + }, + "Item_Code": { + "allOf": [ + { + "minLength": 0, + "maxLength": 50, + "ensureTrim": true, + "example": "98768926735178" + }, + { + "$ref": "#/definitions/generalTextSpace" + } + ] + }, + "Item_Description": { + "allOf": [ + { + "minLength": 1, + "maxLength": 150, + "ensureTrim": true, + "example": "10cm Brush" + }, + { + "$ref": "#/definitions/generalTextSpace" + } + ] + }, + "Item_VATCode": { + "allOf": [ + { + "minLength": 0, + "maxLength": 50, + "ensureTrim": true, + "example": "T1" + }, + { + "$ref": "#/definitions/generalTextSpace" + } + ] + }, + "Item_VATRate": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "Item_NetAmount": { + "$ref": "#/definitions/positivePaymentNullable" + }, + "Item_GrossAmount": { + "$ref": "#/definitions/positivePaymentNullable" + }, + "Item_Quantity": { + "$ref": "#/definitions/quantityLimits" + }, + "Line_TotalAmount": { + "$ref": "#/definitions/positivePayment" + }, + "Line_VATAmount": { + "$ref": "#/definitions/positivePayment" + } + } + } + } +} \ No newline at end of file diff --git a/node_server/schemas/testHelpers.js b/node_server/schemas/testHelpers.js new file mode 100644 index 0000000..db4e98d --- /dev/null +++ b/node_server/schemas/testHelpers.js @@ -0,0 +1,209 @@ +/** + * @fileOverview Providers helper functions for running test cases + */ +/* globals describe, beforeEach, it */ +'use strict'; + +var _ = require('lodash'); +var Q = require('q'); +var validator = require('./validator.js'); +var chai = require('chai'); +var chaiDeepMatch = require('chai-deep-match'); +var chaiAsPromised = require('chai-as-promised'); +var expect = chai.expect; + +module.exports = { + runTestSuite: runTestSuite +}; + +chai.use(chaiDeepMatch); +chai.use(chaiAsPromised); + +/** + * Set the path prefix to a value that will work for these tests + */ +const SCHEMA_ROOT = '../schemas'; + +/** + * Runs a suite of test cases for a number of commands. The expected format is: + * + * { + * : [ + * { + * name: 'Extra test case info, e.g. why it should fail', + * valid: true (or false if the test data should fail to validate) + * data : { + * Param1: 'Value1', + * Param2: 'Value2' + * } + * } + * ], + * : [...etc...] + * } + * + * @param {String} suiteName - The name of the test suite + * @param {Object} testSuite - The test suite config to run + */ +function runTestSuite(suiteName, testSuite) { + describe(suiteName, function() { + /** + * For each Command grouping specified above + */ + _.forEach(testSuite, function(commandTests, commandName) { + describe(commandName, function() { + /** + * Set up the validator with our command only (for isolation) + */ + beforeEach(function() { + validator.initialise([commandName], true, SCHEMA_ROOT); + }); + + /** + * For each test case for that command + */ + _.forEach(commandTests, function(testCase) { + /** + * Build the nice display name for the test case + */ + var testCaseName = 'should '; + if (testCase.valid) { + testCaseName += 'accept valid '; + } else { + testCaseName += 'reject invalid '; + } + testCaseName += commandName; + if (testCase.name) { + testCaseName += ': ' + testCase.name; + } + + /** + * And run the test, expecting validation to succeed or fail as + * specified in the test case + */ + it(testCaseName, function() { + var validationP = validator.validate(commandName, testCase.data); + if (testCase.valid) { + return expect(validationP).to.eventually.be.true; + } else { + return testExpectedFails(validationP, testCase.expect); + } + }); + }); + }); + }); + }); +} + +/** + * Function to test potentially complex expected fails of the validator. + * This allows us to test multiple fail conditions at once (as the validator + * can return all fails) + * + * @param {Promise} validationP - the validation promise to test + * @param {Object?} expectErr - expected error details + * @param {String?} expectErr.count - expected number of errors + * @param {String?} expectErr.errors - array of more detailed expected errors + * + * @returns {Promise} - A promise for the overall evaluated result + */ +function testExpectedFails(validationP, expectErr) { + var expectations = []; + + /** + * Simplest test is that we expect this to be rejected + */ + expectations.push( + expect(validationP).to.eventually.be.rejected + ); + + /** + * For the other validations we need to turn the failed exceptions into + * passed ones, because chai has lots of support for testing successes, + * but not for testing failures. + */ + var inverseP = validationP.catch(function(err) { + return Q.resolve(err); + }); + + /** + * missingRequiredCount is a shortcut for count and errors, so fill in those + * values for the rest of the testing to use + */ + if (expectErr && expectErr.missingRequiredCount) { + if (!_.isUndefined(expectErr.count)) { + return Q.reject('`missingRequiredCount` and `count` are mutually exclusive'); + } + if (!_.isUndefined(expectErr.errors)) { + return Q.reject('`missingRequiredCount` and `errors` are mutually exclusive'); + } + + /** + * Set up the count to be the number given, and the errors to be a + * 'required' failure the correct number of times + */ + expectErr.errors = _.times( + expectErr.missingRequiredCount, + _.constant({keyword: 'required'}) + ); + } + + /** + * If there are expected additional params errors, then add those to the + * the list of expected errors. Note that they come before `required` errors + */ + if (expectErr && expectErr.additionalPropsCount) { + let additionalPropsErrors = _.times( + expectErr.additionalPropsCount, + _.constant({keyword: 'additionalProperties'}) + ); + + if (Array.isArray(expectErr.errors)) { + /* Existing errors to update */ + expectErr.errors = additionalPropsErrors.concat(expectErr.errors); + } else { + /* No existing errors so set it to our errors */ + expectErr.errors = additionalPropsErrors; + } + } + + /** + * If we don't have an error count, but we do have a list of errors, then + * set the error count to the list of errors + */ + if (expectErr && _.isUndefined(expectErr.count) && !_.isUndefined(expectErr.errors)) { + expectErr.count = expectErr.errors.length; + } + + /** + * If we have an error count, then expect that number of errors + */ + if (expectErr && expectErr.count) { + expectations.push( + expect(inverseP).to.eventually + .have.property('errors') + .that.is.an('array') + .that.has.lengthOf(expectErr.count) + ); + } + + /** + * If we have more specific errors then test them + */ + if (expectErr && expectErr.errors) { + /** + * For each error in order + */ + for (let i = 0; i < expectErr.errors.length; ++i) { + /** + * Expect that error to exist and have the expected subset of keys + */ + expectations.push( + expect(inverseP).to.eventually + .have.nested.property('errors[' + i + ']') + .to.deep.match(expectErr.errors[i]) + ); + } + } + + return Q.all(expectations); +} diff --git a/node_server/schemas/utils.spec.js b/node_server/schemas/utils.spec.js new file mode 100644 index 0000000..d4058c9 --- /dev/null +++ b/node_server/schemas/utils.spec.js @@ -0,0 +1,97 @@ +/** + * @fileOverview Unit tests for utility commands schemas + * + */ +'use strict'; +var _ = require('lodash'); +var testHelper = require('./testHelpers.js'); + +/** + * Test data + * Valid postcodes based on Table 16, Page 18 of the PAF Programmers Guide: + * http://www.royalmail.com/sites/default/files/docs/pdf/programmers_guide_edition_7_v5.pdf + */ +const validPostcodes = [ + 'M1 1AA', + 'M60 1NW', + 'CR2 6XH', + 'DN55 1PT', + 'W1P 1BB', + 'EC1A 1BB' +]; + +const invalidPostcodes = [ + true, // Not a string + 1, // Also not a string + 'M 1AA', // Missing the number in the outward code + 'M1 AA', // Missing the numner in the inward code + 'ABC1 1AA', // Too many letters in outward code + 'AB111 1AA', // Too many numbers in outward code + 'M1 11AA', // Too many numbers in inward code + 'M1 1AAA', // Too many letters in inward code + 'M1 A1A' // Letter before number in inward code +]; + +const VALID_SESSION_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; +const VALID_DEVICE_TOKEN = '01234567890abcdefghijklmnopqrstuvwxyzABCDE'; + +let tests = []; +/** + * Add all the valid tests + */ +for (let i = 0; i < validPostcodes.length; ++i) { + let test = { + name: validPostcodes[i], + valid: true, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PostCode: validPostcodes[i] + } + }; + tests.push(test); +} + +/** + * Add all the invalid tests + */ +for (let i = 0; i < invalidPostcodes.length; ++i) { + let test = { + name: invalidPostcodes[i], + valid: false, + data: { + DeviceToken: VALID_DEVICE_TOKEN, + SessionToken: VALID_SESSION_TOKEN, + PostCode: invalidPostcodes[i] + } + }; + tests.push(test); +} + +/** + * Add tests for the structure of the request + */ +tests.push({ + name: 'missing command + unexpected extra param', + valid: false, + expect: { + missingRequiredCount: 3, + additionalPropsCount: 1 + }, + data: { + invalidAdditionalProp: 'a' + } +}); + +/** + * Test suite that defines the test command body and expected outcomes for + * a range of different bodies across different commands. + */ +const TEST_SUITE = { + 'PostCodeLookup': tests +}; + +/** + * Run the test suite through the test runner + */ +testHelper.runTestSuite('Schemas: utils', TEST_SUITE); diff --git a/node_server/schemas/validator.js b/node_server/schemas/validator.js new file mode 100644 index 0000000..eebf79a --- /dev/null +++ b/node_server/schemas/validator.js @@ -0,0 +1,210 @@ +/** + * @fileOverview Wrapper functions for json validator + * + * This loads and initialises the validator with all the applicable schemas. + */ +'use strict'; + +var Ajv = require('ajv'); +var Q = require('q'); +var path = require('path'); +var debugInit = require('debug')('jsonschema:validator:init'); +var debugValidate = require('debug')('jsonschema:validator:validate'); + +/** + * Static variables + */ +var ajv = null; +var diConstructionFunc = defaultDIConstructionFunc; + +module.exports = { + initialise: initialise, + validate: validate, + + setDIValidatorConstructor: setDIValidatorConstructor +}; + +/** + * List of files defining custom keywords for AJV. + * The names MUST match a definition file in customKeywords/.js + */ +const CUSTOM_KEYWORDS = [ + 'maxdp', + 'ensuretrim' +]; +const CUSTOM_KEYWORDS_PATH = 'customKeywords'; + +/** + * List of schema names that we want to load. They will be loaded in the order + * they are defined. + * + * NOTE: schemas for API commands are passed in to the initialise function and + * thus SHOULD NOT be specified here. + */ +const SCHEMAS = [ + /* Contains all the defintions of more specific parameter types.*/ + 'definitions' +]; + +/** + * Options for intialising ajv. + * @see {@link https://github.com/epoberezkin/ajv#options} + */ +var AJV_OPTIONS = { + /* Return all errors, not just the first one */ + allErrors: false, + + /* Validate formats fully. Slower by more correct than 'fast' mode */ + format: 'full', + + /* Throw exceptions during schema compilation for unknown formats */ + unknownFormats: true, + + /* Don't remove additional properties, so that we can detect they exist and fail validation */ + /* If removeAdditional = true, they are removed before they can be detected as additional */ + removeAdditional: false, + + /* Allow use of the default keyword. The default is cloned each time.*/ + useDefaults: true, + + /* Ensure all types are exactly as specified. E.g. this will not accept "1" as a number */ + coerceTypes: false +}; + +/** + * Function to validate data against the schema with the given name. + * + * WARNING: initialise() MUST be called before using this function. + * + * @param {String} schemaName - The name of the schema + * @param {Object} data - The JSON object to validate + * + * @returns {Promise} - resolves to true on success, or rejects with Ajv.ValidationError on fail + */ +function validate(schemaName, data) { + /** + * Run the validation + */ + var result = ajv.validate(schemaName, data); + + /** + * ajv can return a bool or a promise depending on whether it is async. + * To simplify for the callers we convert the bool results to always return a promise. + */ + if (typeof result === 'boolean') { + if (result) { + // Valid + return Q.resolve(true); + } else { + debugValidate('Error: ', ajv.errors); + // Invalid, so reject with a ValidationError containing the errors + return Q.reject(new Ajv.ValidationError(ajv.errors)); + } + } else { + // Already returning a promise, so just return that + return result; + } +} + +/** + * Intialises the validator and loads all the schemas defined at the top of the file + * followed by all the schemas passed in. + * + * @param {String[]} additionalSchemas - array of additional schemas to add (e.g. API commands) + * @param {boolean} isDev - true if we are in a dev environment + * @param {String} schemaRoot - the root directory for the schemas + */ +function initialise(additionalSchemas, isDev, schemaRoot) { + /* Update the allErrors to true only if we are in dev */ + AJV_OPTIONS.allErrors = isDev ? true : false; + + /* Construct the validator using the DI function */ + ajv = diConstructionFunc(AJV_OPTIONS); + + /** + * Add any custom keywords we have + */ + debugInit('Loading [', CUSTOM_KEYWORDS.length, '] keywords.'); + for (var kw = 0; kw < CUSTOM_KEYWORDS.length; ++kw) { + var keywordPath = path.join( + schemaRoot, + CUSTOM_KEYWORDS_PATH, + CUSTOM_KEYWORDS[kw] + ); + var keyword = require(keywordPath); + debugInit(' - Loaded keyword [', keyword.keyword, ']'); + + ajv.addKeyword(keyword.keyword, keyword.definition); + debugInit(' - Added to validator'); + } + debugInit('All keywords loaded'); + + /** + * Join our hardcoded array of schemas to the one passed in + */ + var schemas = SCHEMAS.concat(additionalSchemas); + debugInit('Loading [', schemas.length, '] schemas.'); + + /** + * Load all the schemas into the validator + */ + for (var i = 0; i < schemas.length; ++i) { + var schemaName = schemas[i]; + var schemaPath = path.join(schemaRoot, schemaName); + + debugInit(' - [', schemaName, ']:', schemaPath); + + /* Load the schema. No extension added so will pick .js or .json in that order */ + var schemaObj = require(schemaPath); + + debugInit(' - Loaded schema id [', schemaObj.id, ']'); + + /** + * We want all properties to be fully defined, so we want to check that + * `additionalProperties` has been set in any schema that is an object. + * We don't enforce it to be false, as we may want it to be some other + * value in special cases. But we do enforce its existence. + */ + if ( + schemaObj.hasOwnProperty('type') && + schemaObj.type === 'object' && + !schemaObj.hasOwnProperty('additionalProperties') + ) { + let error = + 'Error loading [' + + schemaPath + + ']: `additionalProperties` MUST be set (usually to `false`)'; + debugInit(' - ' + error); + throw new Error(error); + } + + /* Add the schema to the validator. Schema validation errors will throw an exception*/ + ajv.addSchema(schemaObj, schemaName); + + debugInit(' - Added to validator'); + } + debugInit('All schemas loaded'); +} + +/** + * For testing purposes,this allows us to inject the validator constructor + * function to be used when constructing the validator. + * + * @param {Function} constructorFunc - the constructor function. Takes AJV options and returns ajv + */ +function setDIValidatorConstructor(constructorFunc) { + diConstructionFunc = constructorFunc; +} + +/** + * The default function used to create the ajv validator. This may be replaced + * by a call to setDIValidatorConstructor(), though this should only be needed + * for specific tests. + * + * @param {Object} options - ajv constructor options + * + * @returns {Object} - a new Ajv() instance + */ +function defaultDIConstructionFunc(options) { + return new Ajv(options); +} diff --git a/node_server/schemas/validator.spec.js b/node_server/schemas/validator.spec.js new file mode 100644 index 0000000..add2bf0 --- /dev/null +++ b/node_server/schemas/validator.spec.js @@ -0,0 +1,93 @@ +/** + * @fileOverview Unit tests for the JSON schema validator functions + */ +/* globals describe, beforeEach, it */ + +var validator = require('./validator.js'); +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); +var expect = chai.expect; + +chai.use(chaiAsPromised); + +/** + * Set the path prefix to a value that will work for these tests + */ +const SCHEMA_ROOT = '../schemas'; + +describe('JSON Validator', function() { + + describe('initialise', function() { + /** + * Test of initialising the schemas. + * Note the complicated way we have to pass the initialise function + * to expect() using bind() as expect needs a function to call, not + * the result of a function call. + */ + it('should initialise with no extra schemas', function() { + return expect( + validator.initialise.bind(undefined, [], true, SCHEMA_ROOT) + ).to.not.throw(); + }); + + it('should initialise with a valid extra schema', function() { + return expect( + validator.initialise.bind(undefined, ['Login1'], true, SCHEMA_ROOT) + ).to.not.throw(); + }); + + it('should throw an exception with an invalid extra schema', function() { + return expect( + validator.initialise.bind(undefined, ['DOSENT_EXIST'], true, SCHEMA_ROOT) + ).to.throw(/Cannot find module/); + }); + }); + + describe('validation', function() { + beforeEach(function() { + validator.initialise(['Login1'], true, SCHEMA_ROOT); + }); + + it('should validate a properly formatted body', function() { + var validLogin1 = { + ClientName: 'someone@example.com', + DeviceToken: '01234567890abcdefghijklmnopqrstuvwxyzABCDE', + DeviceAuthorisation: '01234567890abcdef01234567890abcdef01234567890abcdef01234567890ab', + APIVersion: '0.0', + DeviceHardware: 'Chai Tests', + DeviceSoftware: 'Chai Tests', + Latitude: -90.0, + Longitude: 180.0 + }; + return expect( + validator.validate('Login1', validLogin1) + ).to.eventually.be.true; + }); + + it('should reject an invalid body', function() { + var invalidLogin1 = { + ClientName: 'someone@example.com' + }; + return expect( + validator.validate('Login1', invalidLogin1) + ).to.eventually.be.rejected; + }); + + it('should reject a body with extra parameters', function() { + var validLogin1 = { + ClientName: 'someone@example.com', + DeviceToken: '01234567890abcdefghijklmnopqrstuvwxyzABCDE', + DeviceAuthorisation: '01234567890abcdef01234567890abcdef01234567890abcdef01234567890ab', + APIVersion: '0.0', + DeviceHardware: 'Chai Tests', + DeviceSoftware: 'Chai Tests', + Latitude: -90.0, + Longitude: 180.0, + ExtraParam1: 0 + }; + return expect( + validator.validate('Login1', validLogin1) + ).to.eventually.be.rejected; + }); + }); +}); diff --git a/node_server/swagger_api/api_body_middleware.js b/node_server/swagger_api/api_body_middleware.js new file mode 100644 index 0000000..c85d723 --- /dev/null +++ b/node_server/swagger_api/api_body_middleware.js @@ -0,0 +1,46 @@ +/** + * @fileoverview Middleware to both parse the request body as JSON, and keep a raw copy of it + * for HMAC calculations in `req.bodyRaw`. + */ + +const bodyParser = require('body-parser'); +const iconv = require('iconv-lite'); + +const utils = require('../ComServe/utils.js'); + +module.exports = { + bridgeBodyParser +}; + +/** + * This mildly abuses the `verify` callback in bodyParser.json() middleware to + * store the raw body in `req.bodyRaw` so we can use it to correctly verify the HMAC. + * This is called before bodyParser has done its own decoding to string so we + * have to repeat that ourselves. + * We don't do any real verification, though the encoding handling could cause an + * exception to be thrown. + * + * @param {Object} req - the express request object + * @param {Object} res - the express response object + * @param {Object} buf - the buffer containing the raw body + * @param {string} encoding - the specified encoding + */ +function storeRawBody(req, res, buf, encoding) { + if (encoding !== null) { + req.bodyRaw = iconv.decode(buf, encoding); + } +} + +/** + * Factory function to generate the middleware we need to store the raw and parsed + * bodies in the request. We mostly use the `body-parser` from Express, with + * our own function as a fake verifier to store the raw body. + * We also limit the max size of body we allow according to the setting in utils. + */ +function bridgeBodyParser() { + return bodyParser.json({ + limit: utils.maxPacketSize, + verify: storeRawBody + }); +} + diff --git a/node_server/swagger_api/api_cors_middleware.js b/node_server/swagger_api/api_cors_middleware.js new file mode 100644 index 0000000..7bc6cd6 --- /dev/null +++ b/node_server/swagger_api/api_cors_middleware.js @@ -0,0 +1,220 @@ +// +// Middleware to support adding appropriate Cross-Origin Resource Sharing (CORS) +// headers to the server to allow javascript running on other domains to access +// this API. +// @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS} +// +// This is modified from MIT licensed code by James Messinger: +// @see {@link https://github.com/BigstickCarpet/swagger-express-middleware/blob/master/lib/cors.js} +// +'use strict'; +var debug = require('debug')('webconsole-api:cors-middleware'); +var config = require(global.configFile); +var _ = require('lodash'); + +// +// Define the exports +// +module.exports = CORS; + +/* + * Define the protocol for the web console's host. This should usually be https + */ +const ORIGIN_PROTOCOL = 'https'; + +var swaggerMethods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch']; + +// The CORS headers +var accessControl = { + allowOrigin: 'access-control-allow-origin', + allowMethods: 'access-control-allow-methods', + allowHeaders: 'access-control-allow-headers', + exposeHeaders: 'access-control-expose-headers', // Expose custom headers to browser/js + allowCredentials: 'access-control-allow-credentials', + maxAge: 'access-control-max-age' +}; + +/** + * Handles CORS preflight requests and sets CORS headers for all requests + * according the Swagger API definition. + * + * @return {function[]} Array of middleware functions to run. + */ +function CORS() { + return [corsHeaders, corsPreflight]; +} + +/** + * Sets the CORS headers. If default values are specified in the Swagger API, + * then those values are used. Otherwise, sensible defaults are used. + * + * @param {Object} req - the request + * @param {Object} res - the response object + * @param {Callbacl} next - the callback for the next middleware in the chain + */ +function corsHeaders(req, res, next) { + // Get the default CORS response headers as specified in the Swagger API + var responseHeaders = getResponseHeaders(req); + + // Set each CORS header + _.each(accessControl, function(header) { + if (responseHeaders[header] !== undefined) { + // Set the header to the default value from the Swagger API + res.set(header, responseHeaders[header]); + } else { + // Set the header to a sensible default + switch (header) { + case accessControl.allowOrigin: + // By default, allow the host defined in the config. + res.set( + header, + ORIGIN_PROTOCOL + '://' + config.webconsole.host + ); + break; + + case accessControl.allowMethods: + if (req.swagger && req.swagger.path) { + var allowedMethods = swaggerMethods + .filter(function(method) { + return !!req.swagger.path[method]; + }) + .join(', ') + .toUpperCase(); + // Return the allowed methods for this Swagger path + res.set(header, allowedMethods); + } else { + // By default, allow all of the requested methods. + // Fallback to ALL methods. + res.set( + header, + req.get('Access-Control-Request-Method') || + swaggerMethods.join(', ').toUpperCase()); + } + break; + + case accessControl.allowHeaders: + // By default, allow all of the requested headers + res.set( + header, + req.get('Access-Control-Request-Headers') || '' + ); + break; + + case accessControl.allowCredentials: + // By default, allow credentials + res.set(header, true); + break; + + case accessControl.maxAge: + // By default, access-control expires immediately. + res.set(header, 0); + break; + + case accessControl.exposeHeaders: + // By default, allow our session timeout remaining header + res.set(header, 'X-BRIDGE-SESSION-EXPIRY'); + break; + } + } + }); + + if (res.get(accessControl.allowOrigin) === '*') { + // If Access-Control-Allow-Origin is wild-carded, then + // `Access-Control-Allow-Credentials` must be false + res.set('Access-Control-Allow-Credentials', 'false'); + } else { + // If Access-Control-Allow-Origin is set (not wild-carded), + // then `Vary: Origin` must be set + res.vary('Origin'); + } + + next(); +} + +/** + * Handles CORS preflight requests. + * + * @param {Object} req - the request + * @param {Object} res - the response object + * @param {Callbacl} next - the callback for the next middleware in the chain + */ +function corsPreflight(req, res, next) { + if (req.method === 'OPTIONS') { + debug( + 'OPTIONS %s is a CORS preflight request. Sending HTTP 200 response.', + req.path); + res.send(); + } else { + next(); + } +} + +/** + * Returns an object containing the CORS response headers that are defined in + * the Swagger API. If the same CORS header is defined for multiple responses, + * then the first one wins. + * + * @param {Request} req - the request + * @returns {object} - Any defined headers from the swagger def. + */ +function getResponseHeaders(req) { + var corsHeaders = {}; + if (req.swagger) { + var headers = []; + + if (req.method !== 'OPTIONS') { + // This isn't a preflight request, so the operation's response + // headers take precedence over the OPTIONS headers + headers = getOperationResponseHeaders(req.swagger.operation); + } + + if (req.swagger.path) { + // Regardless of whether this is a preflight request, append the + // OPTIONS response headers + headers = headers.concat( + getOperationResponseHeaders(req.swagger.path.options) + ); + } + + // Add the headers to the `corsHeaders` object. First one wins. + headers.forEach(function(header) { + if (corsHeaders[header.name] === undefined) { + corsHeaders[header.name] = header.value; + } + }); + } + + return corsHeaders; +} + +/** + * Returns all response headers for the given Swagger operation, sorted by + * HTTP response code. + * + * @param {object} operation - The Operation object from the Swagger API + * @returns {{responseCode: integer, name: string, value: string}[]} - response + * headers for the operation. + */ +function getOperationResponseHeaders(operation) { + var headers = []; + + if (operation) { + _.each(operation.responses, function(response, responseCode) { + // Convert responseCode to a numeric value for sorting ("default" comes last) + responseCode = parseInt(responseCode, 10) || 999; + + _.each(response.headers, function(header, name) { + // We only care about headers that have a default value defined + if (header.default !== undefined) { + headers.push({ + order: responseCode, + name: name.toLowerCase(), + value: header.default + }); + } + }); + }); + } + + return _.sortBy(headers, 'order'); +} diff --git a/node_server/swagger_api/api_definitions.json b/node_server/swagger_api/api_definitions.json new file mode 100644 index 0000000..557222e --- /dev/null +++ b/node_server/swagger_api/api_definitions.json @@ -0,0 +1,2001 @@ +{ + "definitions": { + "CspReport": { + "description": "De-facto CSP report format per https://www.tollmanz.com/content-security-policy-report-samples/", + "type": "object", + "properties": { + "blocked-uri": { + "type": "string" + }, + "document-uri": { + "type": "string" + }, + "effective-directive": { + "type": "string" + }, + "original-policy": { + "type": "string" + }, + "referrer": { + "type": "string" + }, + "status-code": { + "type": "integer" + }, + "violated-directive": { + "type": "string" + }, + "source-file": { + "type": "string" + }, + "line-number": { + "type": "integer" + }, + "column-number": { + "type": "integer" + }, + "request": { + "type": "string" + }, + "request-headers": { + "type": "string" + }, + "script-sample": { + "type": "string" + } + } + }, + "imageDataUri": { + "description": "RFC 2397 compliant data URI, constrained to base64 encoded images", + "type": "string", + "pattern": "^data:image\\/(png|jpeg);base64,[A-Za-z0-9\\/+]+(={0,2})$", + "minLength": 4, + "maxLength": 50000, + "example": "data:image/png;base64,a1B2c3==" + }, + "email": { + "description": "basic email address parsing. See http://www.regular-expressions.info/email.html", + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "x-invalid-pattern": "[^a-zA-Z0-9.%+-.@]", + "minLength": 7, + "maxLength": 254, + "example": "admin@example.com" + }, + "uuid": { + "description": "reference to another object in the database", + "type": "string", + "pattern": "^([a-f0-9]{24})$", + "x-invalid-pattern": "[^a-f0-9]", + "minLength": 24, + "maxLength": 24, + "example": "12a345b67c8901234d567e89" + }, + "DeviceHardware": { + "description": "The device hardware type (as specified by the manufacturer).", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 75 + } + ], + "example":"iphone 5S" + }, + "DeviceSoftware": { + "description": "The software type and version at registration (not updated)", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 75 + } + ], + "example":"IOS 10" + }, + "BridgeID": { + "description": "A unique reference", + "type": "string", + "pattern": "\\d{8}T\\d{9}[A-z\\d]{14}", + "x-invalid-pattern": "[^0-9A-z]", + "minLength": 32, + "maxLength": 32 + }, + "ImageRef": { + "description": "reference to an image in the database with optional defaults.", + "type": "string", + "pattern": "^([a-f0-9]{24}|(defaultSelfie)|(defaultCompanyLogo0))$", + "minLength": 13, + "maxLength": 24, + "example": "12a345b67c8901234d567e89" + }, + "uuidNullable": { + "description": "reference to another object in the database or `null`", + "type": [ "null", "string" ], + "pattern": "^([a-f0-9]{24})$", + "x-invalid-pattern": "[^a-f0-9]", + "minLength": 24, + "maxLength": 24, + "example": "12a345B67c8901234D567e89" + }, + "hex256": { + "description": "A 256bit hex value", + "type": "string", + "pattern": "^([a-f0-9]{64})$", + "x-invalid-pattern": "[^a-f0-9]", + "minLength": 64, + "maxLength": 64, + "example": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "generalText": { + "description": "General text format + special chars", + "type": "string", + "pattern": "^([A-Za-z0-9'[\\]()@?!\\-/.,_&*:;+=]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9'[\\]()@?!\\-/.,_&*:;+=]", + "example": "SomeTextWithoutSpacesButWith'&','*',etc." + }, + "generalTextSpace": { + "description": "General text format + special chars + space", + "type": "string", + "pattern": "^([A-Za-z0-9'[\\]()@?!\\-/.,_&*:;+= ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9'[\\]()@?!\\-/.,_&*:;+= ]", + "example": "Some Text With Spaces plus '&', '*', etc." + }, + "generalTextSpaceNullable": { + "description": "General text format + special chars + space or `null`", + "type": [ "null", "string" ], + "pattern": "^([A-Za-z0-9'[\\]()@?!\\-/.,_&*:;+= ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9'[\\]()@?!\\-/.,_&*:;+= ]", + "example": "Some Text With Spaces plus '&', '*', etc." + }, + "fullAlphaNumeric": { + "description": "Text with only ASCII letters + numbers", + "type": "string", + "pattern": "^([A-Za-z0-9]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9]", + "example": "SomeTextWithoutSpacesOrSpecialCharsButWith0123456789" + }, + "fullAlphaNumericDashSpace": { + "description": "Text with only ASCII letters + numbers + dash", + "type": "string", + "pattern": "^([A-Za-z0-9\\- ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9\\- ]", + "example": "Some text with spaces plus 0123456789 And -" + }, + "fullAlphaNumericDashSpaceNullable": { + "description": "Text with only ASCII letters + numbers + dash", + "type": [ "null", "string" ], + "pattern": "^([A-Za-z0-9\\- ]*)$", + "x-invalid-pattern": "[^a-zA-Z0-9\\- ]", + "example": "Some text with spaces plus 0123456789 And -" + }, + "alpha": { + "description": "Text with only ASCII letters", + "type": "string", + "pattern": "^([A-Za-z]*)$", + "x-invalid-pattern": "[^A-Za-z]", + "example": "SomeTextWithOnlyAsciiLetters" + }, + "alphaSpace": { + "description": "Text with only ASCII letters and space", + "type": "string", + "pattern": "^([A-Za-z ]*)$", + "x-invalid-pattern": "[^A-Za-z ]", + "example": "Some Text With Only Ascii Letters plus space" + }, + "alphaSpaceNullable": { + "description": "Optional text with only ASCII letters and space", + "type": [ "null", "string" ], + "pattern": "^([A-Za-z ]*)$", + "x-invalid-pattern": "[^A-Za-z ]", + "example": "Some Text With Only Ascii Letters plus space" + }, + "alphaDashSpace": { + "description": "Text with only ASCII letters, dash, and space", + "type": "string", + "pattern": "^([A-Za-z\\- ]*)$", + "x-invalid-pattern": "[^A-Za-z\\- ]", + "example": "Some Text With Only Ascii Letters plus space plus -" + }, + "paycodeString": { + "description": "Paycode string. Mostly 0-9A-Z with some ambiguous letters removed", + "type": "string", + "pattern": "^([0-9ABCDEFGHJKLMNPRSTUVWXYZ]*)$", + "x-invalid-pattern": "[^0-9ABCDEFGHJKLMNPRSTUVWXYZ]", + "example": "A1A1A" + }, + "lowerCaseHex": { + "description": "Lower case, hexadecimal string (for hashes etc.)", + "type": "string", + "pattern": "^([a-f0-9]*)$", + "x-invalid-pattern": "[^a-f0-9]", + "example": "f0a1" + }, + "numeric": { + "description": "Numbers only, but sent as a string", + "type": "string", + "pattern": "^([0-9]*)$", + "x-invalid-pattern": "[^0-9]", + "example": "123" + }, + "version": { + "description": "Version number formatting", + "type": "string", + "pattern": "^([a-z0-9.\\-]*)$", + "x-invalid-pattern": "[^a-z0-9.\\-]", + "example": "0.0.0-abcdefg1234" + }, + "accountNumberAnon": { + "description": "An anonymised account number", + "type": "string", + "pattern": "^\\*{5}[0-9]{3}$", + "example": "*****234" + }, + "SortCodeAnon": { + "description": "An anonymised sort code", + "type": "string", + "pattern": "^\\*{2}-\\*{2}-[0-9]{2}", + "example": "**-**-12" + }, + "CardPanAnon": { + "description": "An anonymised card number", + "type": "string", + "pattern": "^[0-9][* ]*[0-9 ]{3,4}$", + "example": "0*** **** **** *234" + }, + "MerchantIdAnon": { + "description": "An anonymised AcquirerMerchantId", + "type": "string", + "pattern": "^\\*{5}[0-9a-zA-Z]{3}$", + "example": "*****A3b" + }, + "WorldpayMerchantId": { + "description": "The Worldpay MerchantID format.", + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "example": "4db79f58-b8e8-4485-9346-1aafe16ffc57", + "x-invalid-pattern": "[^0-9a-f\\-]" + }, + "WorldpayServiceKey": { + "description": "The Worldpay Service Key format.", + "type": "string", + "pattern": "^(?:T_S_|T_C_|L_S_|L_C_)[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "example": "T_S_4db79f58-b8e8-4485-9346-1aafe16ffc57", + "x-invalid-pattern": "[^0-9a-f\\-_TLSC]" + }, + "cardDate": { + "description": "Dates on cards.", + "type": "string", + "pattern": "^(?:0[1-9]|1[0-2])-[0-9][0-9]$", + "x-invalid-pattern": "[^0-9\\-]" + }, + "phoneNumber": { + "description": "Phone number for a mobile device (with all spaces or dashes removed)", + "type": "string", + "pattern": "^\\+([0-9]*)$", + "x-invalid-pattern": "[^0-9+]", + "minLength": 8, + "maxLength": 35, + "example": "+447700900000" + }, + "phoneNumberAnon": { + "description": "Phone number for a mobile device, anonymised", + "type": "string", + "pattern": "^\\+[0-9]{2,3} [0-9][ *]*[0-9]{3}$", + "minLength": 8, + "maxLength": 35, + "example": "+44 1*** ***000" + }, + "phoneNumberNullable": { + "description": "Phone number for a mobile device (with all spaces or dashes removed)", + "type": [ "null", "string" ], + "pattern": "^\\+([0-9]*)$", + "x-invalid-pattern": "[^0-9+]", + "minLength": 8, + "maxLength": 35, + "example": "+447700900000" + }, + "phoneNumberAnonNullable": { + "description": "Phone number for a mobile device, anonymised", + "type": [ "null", "string" ], + "pattern": "^\\+[0-9]{2,3} [0-9][ *]*[0-9]{3}$", + "minLength": 8, + "maxLength": 35, + "example": "+44 1*** ***000" + }, + "featureFlags": { + "description": "Flags for extra features enabled for the client", + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "example": ["test"] + }, + "geojson-point": { + "description": "A Geo-JSON point (easting, northing), or null if location unknown", + "type": [ "null", "object" ], + "example": {"type": "Point", "coordinates": [-3.548724, 55.872482]}, + "properties": { + "type": { + "description": "Defines that this is a geo-json point (as opposed to other geo-json primitives", + "type": "string", + "enum": [ "Point" ] + }, + "coordinates": { + "description": "Co-ordinates in [0]=easting, [1]=northing order", + "type": "array", + "items": { + "description": "Coordinate. Note that northing is limited to +/- 90, not +/- 180.", + "type": "number", + "maximum": 180, + "minimum": -180 + }, + "maxItems": 2, + "minItems": 2 + } + } + }, + "ipNullable": { + "description": "An IP address (either IPv4 or IPv6), or null if none", + "type": [ "null", "string" ], + "pattern": "^((?:[0-9]{1,3}.){3}[0-9]{1,3}|(?:[0-9a-zA-Z]{0,4}\\:){2,7}[0-9a-zA-Z]{1,4})$", + "minLength": 3, + "maxLength": 32, + "example": "::1" + }, + "loginMethod": { + "description": "Valid login methods", + "type": "string", + "enum": [ "Bridge" ], + "example": "Bridge", + "default": "Bridge" + }, + "operatorName": { + "description": "The operator for this account (e.g. Comcarde, etc.)", + "type": "string", + "enum": [ "Comcarde" ], + "example": "Comcarde", + "default": "Comcarde" + }, + "accountType": { + "description": "Type of an end user account", + "type": "string", + "enum": [ + "Credit/Debit Receiving Account", + "Credit/Debit Payment Card", + "Bank Account", + "Bridge eCash" + ], + "example": "Credit/Debit Payment Card" + }, + "clientImageType": { + "description": "Identifies the client image to use for an account", + "type": "string", + "enum": [ + "defaultSelfie", + "Selfie", + "defaultCompanyLogo0", + "CompanyLogo0", + "Item" + ], + "example": "defaultSelfie" + }, + "clientImageUploadType": { + "description": "Identifies the client image types that can be uploaded", + "type": "string", + "enum": [ + "Selfie", + "CompanyLogo0", + "Item" + ], + "example": "defaultSelfie" + }, + "kycGender": { + "description": "The gender as required by the ID verification/AML service.", + "type": "string", + "enum":[ + "", + "M", + "F" + ] + }, + "security.Device.sessionHeader": { + "description": "The format for the session info header - `x-bridge-device-session` for the bridge_device security layer. It must be formatted as `:`.", + "type": "string", + "minLength": 85, + "maxLength": 85, + "pattern": "[A-Za-z0-9]{42}:[A-Za-z0-9]{42}", + "example": "ABCDEFGHIJKLMONPQRSTUVWXYZabcdefghijklmnop:0123456789qrstuvwxyzABCDEFGHIJKLMNOPQRSTUV" + }, + "security.Device.hmacHeader": { + "description": "The format for the HMAC header - `x-bridge-hmac`. It is HMAC-SHA-256 of ''", + "$ref": "#/definitions/hex256" + }, + "security.Device.hmacTimestamp": { + "description": "The format for the HMAC timestamp header - `x-bridge-timestamp`. It is ISO-8601 timestamp: `YYYY-MM-DDTHH:MM:SS.sssZ`", + "type": "string", + "format": "date-time" + }, + "question": { + "description": "A question that must be answered to confirm knowledge of customer details", + "type": "object", + "properties": { + "questionID": { + "description": "ID of the question to tie up with answers when submitted to the server", + "$ref": "api_definitions.json#/definitions/BridgeID" + }, + "questionType": { + "description": "The type of question to ask the user.", + "type":"string", + "enum": [ + "postcode", + "card", + "transactions", + "device", + "dob" + ] + }, + "questionText": { + "description": "Extra info for the question. Usually the name of item being asked about.", + "$ref": "api_definitions.json#/definitions/generalTextSpace" + } + }, + "required": [ "questionID", "questionType", "questionText" ] + }, + "answer": { + "description": "The answer to a customer verification question", + "type": "object", + "properties": { + "questionID": { + "description": "ID of the question to tie up with this answer on the server", + "$ref": "api_definitions.json#/definitions/BridgeID" + }, + "answer": { + "description": "The answer to the question asked of the user.", + "$ref": "api_definitions.json#/definitions/generalTextSpace" + } + }, + "required": [ "questionID", "answer" ] + }, + "ErrorInfo": { + "description": "More information on the error reason", + "type": "object", + "properties": { + "code": { + "description": "Error code", + "type": "integer", + "default": -1, + "example": 1 + }, + "info": { + "description": "Text description of the issue", + "type": "string", + "default": "Unknown Error", + "example": "Some error" + } + } + }, + "SuccessInfo": { + "description": "More information on the error reason", + "type": "object", + "properties": { + "code": { + "description": "Success code", + "type": "integer", + "default": -1, + "example": 10000 + }, + "info": { + "description": "Text description of the success", + "type": "string", + "default": "Unknown Success", + "example": "Some success" + } + } + }, + "deviceAuthorisation": { + "description": "The Pin for this device", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/numeric" }, + { + "minLength": 5, + "maxLength": 1024 + } + ], + "example": "12345" + }, + "DeviceUuid": { + "description": "Unique and hidden identifier for the device (created by client)", + "allOf": [ + { + "minLength": 30, + "maxLength": 150 + }, + { + "$ref": "#/definitions/generalTextSpace" + } + ] + }, + "token": { + "description": "A random token", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/fullAlphaNumeric" }, + { + "minLength": 42, + "maxLength": 42 + } + ], + "example": "abcdefghijklmnopqrstuvwxyzABCDEF0123456789" + }, + "image": { + "type": "object", + "properties": { + "ImageID": { + "description": "Unique id of the image", + "$ref": "api_definitions.json#/definitions/ImageRef", + "readOnly": true + }, + "ImageFile": { + "description": "The image data encoded as base64.", + "$ref": "api_definitions.json#/definitions/imageDataUri" + }, + "FileType": { + "description": "The file type (from the allowed set)", + "type": "string", + "enum": [ + "JPG", + "JPEG", + "PNG" + ] + }, + "ImageType": { + "description": "The type of image (customer or company image etc.)", + "$ref": "api_definitions.json#/definitions/clientImageType" + }, + "ImageReported": { + "description": "Defines if this user has reported this image", + "type": "boolean", + "readOnly": true + } + }, + "required": [ "ImageFile", "FileType", "ImageType" ] + }, + "imageUpload": { + "type": "object", + "properties": { + "ImageFile": { + "description": "The image data encoded as a png or jpeg Data URI.", + "$ref": "api_definitions.json#/definitions/imageDataUri" + }, + "ImageType": { + "description": "The type of image (customer or company image etc.)", + "$ref": "api_definitions.json#/definitions/clientImageUploadType" + } + }, + "required": [ "ImageFile", "ImageType" ] + }, + "User": { + "type": "object", + "properties": { + "ClientName": { + "description": "Client's email address", + "$ref": "api_definitions.json#/definitions/email" + }, + "DisplayName": { + "description": "Client's display name", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/alphaSpaceNullable" }, + { + "minLength": 2, + "maxLength": 101 + } + ] + }, + "Selfie": { + "description": "The image to use for the client", + "$ref": "api_definitions.json#/definitions/ImageRef" + } + } + }, + "kyc": { + "type": "object", + "description": "Know Your Customer (KYC) data. When KYC is not set, you may receive an empty string from the server for `Gender`, but it MUST NOT be an empty string in set requests.", + "properties": { + "Title": { + "description": "Client's title (Mr, Mrs, Ms, Dr, etc.", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/alphaSpaceNullable" }, + { + "minLength": 2, + "maxLength": 20 + } + ] + }, + "FirstName": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/alphaSpaceNullable" }, + { + "minLength": 2, + "maxLength": 50 + } + ] + }, + "LastName": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/alphaSpaceNullable" }, + { + "minLength": 2, + "maxLength": 50 + } + ] + }, + "MiddleNames": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/alphaSpaceNullable" }, + { + "minLength": 0, + "maxLength": 50 + } + ] + }, + "DateOfBirth": { + "description": "Date of birth as an ISO8601 full-date (YYYY-MM-DD)", + "type": [ "null", "string" ], + "format": "date" + }, + "ResidentialAddressID": { + "$ref": "api_definitions.json#/definitions/uuidNullable" + }, + "Gender": { + "$ref": "api_definitions.json#/definitions/kycGender" + } + }, + "required": ["Title", "FirstName", "LastName", "DateOfBirth", "ResidentialAddressID", "Gender"] + }, + "merchant": { + "type": "object", + "description": "Information on the Client's merchant", + "properties": { + "CompanyName": { + "description": "The company name as registered at Companies' House.", + "type": [ "null", "string" ], + "pattern": "^[AÀÁÂÃÄÅĀĂĄǺÆǼBCÇĆĈĊČDÞĎĐEÈÉÊËĒĔĖĘĚFGĜĞĠĢHĤĦIÌÍÎÏĨĪĬĮİJĴKĶLĹĻĽĿŁMNÑŃŅŇŊOÒÓÔÕÖØŌŎŐǾŒPQRŔŖŘSŚŜŞŠTŢŤŦUÙÚÛÜŨŪŬŮŰŲVWŴẀẂẄXYỲÝŶŸZŹŻŽ&@£$€¥0-9.,:;\\-‘’'()[\\]{}<>!«»“”\"?\\\\/*=#%+ ]*$", + "x-invalid-pattern": "[^AÀÁÂÃÄÅĀĂĄǺÆǼBCÇĆĈĊČDÞĎĐEÈÉÊËĒĔĖĘĚFGĜĞĠĢHĤĦIÌÍÎÏĨĪĬĮİJĴKĶLĹĻĽĿŁMNÑŃŅŇŊOÒÓÔÕÖØŌŎŐǾŒPQRŔŖŘSŚŜŞŠTŢŤŦUÙÚÛÜŨŪŬŮŰŲVWŴẀẂẄXYỲÝŶŸZŹŻŽ&@£$€¥0-9.,:;\\-‘’'()[\\]{}<>!«»“”\"?\\\\/*=#%+ ]", + "minLength": 1, + "maxLength": 160 + }, + "CompanyAlias": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 2, + "maxLength": 50 + } + ] + }, + "CompanySubName": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 0, + "maxLength": 50 + } + ] + }, + "VATNo": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 3, + "maxLength": 25 + } + ] + }, + "CompanyLogo": { + "$ref": "api_definitions.json#/definitions/ImageRef" + } + }, + "required": ["CompanyName", "CompanyAlias"] + }, + "address": { + "type": "object", + "description": "Address", + "properties": { + "AddressID": { + "description": "The id of this address", + "$ref": "api_definitions.json#/definitions/uuid", + "readOnly": true + }, + "AddressDescription": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 2, + "maxLength": 150 + } + ] + }, + "BuildingNameFlat": { + "description": "Building name or flat number", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 1, + "maxLength": 64 + } + ] + }, + "Address1": { + "description": "First line of address", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 4, + "maxLength": 64 + } + ] + }, + "Address2": { + "description": "Second line of address", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 4, + "maxLength": 64 + } + ] + }, + "Town": { + "description": "Postal Town", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 3, + "maxLength": 32 + } + ] + }, + "County": { + "description": "County / Region", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 3, + "maxLength": 32 + } + ] + }, + "PostCode": { + "description": "Post code", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/fullAlphaNumericDashSpace" }, + { + "minLength": 3, + "maxLength": 32 + } + ] + }, + "Country": { + "description": "Country. Only open to UK residents at present", + "type": "string", + "enum": [ "United Kingdom" ] + }, + "PhoneNumberAnon": { + "description": "An anonymised phone number returned from queries", + "$ref": "api_definitions.json#/definitions/phoneNumberAnonNullable" + }, + "PhoneNumber": { + "description": "A contact number at this address; ideally a land line", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/phoneNumberNullable" }, + { + "minLength": 8, + "maxLength": 35 + } + ] + } + } + }, + "transaction": { + "type": "object", + "properties": { + "TransactionID": { + "description": "The id of the transaction", + "$ref": "api_definitions.json#/definitions/uuid", + "readOnly": true + }, + "TransactionType": { + "description": "The type of transaction: 0=Outgoing, 1=Incoming, 2=Outgoing Full Refund, 3=Incoming Full Refund, 4=Outgoing Parial Refund, 5=Incoming Full Refind, 6=Outgoing Manual Correction, 7=Incoming Manual Correction, 8=Aborted Outgoing Transaction, 9=Aborted Incoming Transaction", + "type": "integer", + "enum": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] + }, + "AccountID": { + "description": "The account id with which this transaction is associated", + "$ref": "api_definitions.json#/definitions/uuid" + }, + "OtherDisplayName": { + "description": "Display name of the other party", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 100 + } + ] + }, + "OtherSubDisplayName": { + "description": "Sub display name of the other party", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 100 + } + ] + }, + "OtherImage": { + "description": "Image reference of the other party", + "$ref": "api_definitions.json#/definitions/ImageRef" + }, + "TotalAmount": { + "description": "Total amount (request + tip) in pence (100 = £1.00)", + "type": "number" + }, + "SaleTime": { + "description": "Time of the sale (UTC)", + "type": "string", + "format": "date-time" + }, + "MyLocation": { + "description": "My location during the transaction", + "$ref": "api_definitions.json#/definitions/geojson-point" + }, + "MerchantInvoiceNumber": { + "description": "Monotonically increasing invoice number (per Merchant)", + "type": "number", + "minimum": 0, + "readOnly": true + } + } + }, + "transactionDetail": { + "type": "object", + "properties": { + "OtherDisplayName": { + "description": "Display name of the other party", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 100 + } + ] + }, + "OtherSubDisplayName": { + "description": "Sub display name of the other party", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 100 + } + ] + }, + "OtherImage": { + "description": "Image reference of the other party", + "$ref": "api_definitions.json#/definitions/ImageRef" + }, + "TransactionStatus": { + "description": "Status code for the transaction", + "type": "integer", + "enum": [ 0, 1, 2, 3, 4, 10, 11, 12, 13, 14, 15, 16, 17 ] + }, + "StatusInfo": { + "description": "Textual description of the current status", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "maxLength": 100 + } + ] + }, + "MerchantInvoice": { + "description": "Array of invoiceItems or `null` if no invoice details.", + "type": [ "null", "array" ], + "items": { + "$ref": "api_definitions.json#/definitions/invoiceItem" + } + }, + "MerchantComment": { + "description": "Optional free-text annotation from merchant.", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "maxLength": 300 + } + ], + "example": "You were served today by Richard" + }, + "MerchantVATNo": { + "description": "Vat number. `null` if unregistered", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "maxLength": 50 + } + ], + "example": "GB 123 456 789" + }, + "RequestAmount": { + "description": "Request amount in pence (100 = £1.00)", + "type": "number", + "minimum": 0 + }, + "TipAmount": { + "description": "Tip amount in pence (100 = £1.00)", + "type": ["null", "number"], + "minimum": 0 + }, + "TotalAmount": { + "description": "Total amount (request + tip) in pence (100 = £1.00)", + "type": "number", + "minimum": 0 + }, + "AmountRefunded": { + "description": "Total of all refunds in pence (100 = £1.00)", + "type": "number", + "minimum": 0 + }, + "MyLocation": { + "description": "My location during the transaction", + "$ref": "api_definitions.json#/definitions/geojson-point" + }, + "SaleTime": { + "description": "Time of the sale (UTC)", + "type": "string", + "format": "date-time" + }, + "MerchantInvoiceNumber": { + "description": "Monotonically increasing invoice number (per Merchant)", + "type": "number", + "minimum": 0, + "readOnly": true + } + } + }, + "invoiceItem": { + "type": "object", + "properties": { + "Item_ID": { + "description": "ID of the merchant item", + "$ref": "api_definitions.json#/definitions/uuidNullable" + }, + "Item_Code": { + "description": "Barcode or merchant code", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 50 + } + ], + "example": "98768926735178" + }, + "Item_Description": { + "description": "Free form description", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 1, + "maxLength": 150 + } + ], + "example": "10cm Brush" + }, + "Line_TotalAmount": { + "description": "Total amount for an invoice line in pence (100 = £1.00).", + "type": "integer", + "example": 798 + }, + "Line_VATAmount": { + "description": "VAT amount for an invoice line in pence (100 = £1.00).", + "type": "integer", + "example": 798 + }, + "Item_VATCode": { + "description": "VAT Code information (freeform)", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 50 + } + ], + "example": "T1" + }, + "Item_VATRate": { + "description": "VAT rate in basis points (100 = 1%)", + "type": "integer", + "minimum": 0, + "example": 2000 + }, + "Item_NetAmount": { + "description": "Per item cost net of VAT in pence (100 = £1.00), or NULL if using gross amounts", + "type": ["null", "integer"], + "example": 638 + }, + "Item_GrossAmount": { + "description": "Per item cost gross of VAT in pence (100 = £1.00), or NULL if using net amounts", + "type": ["null", "integer"], + "example": 160 + }, + "Item_Quantity": { + "description": "Quantity of this item", + "type": "number", + "minimum": 0, + "example": 2 + }, + "Item_Refunded": { + "description": "Array of refunds given on this item, or `null` if no refunds", + "type": [ "null", "array" ], + "items": { + "$ref": "api_definitions.json#/definitions/refundItem" + } + }, + "Item_LoyaltyPoints": { + "description": "The loyalty points for this product (or null if not set).", + "type": [ "null", "integer" ], + "minimum": 0, + "default": null + } + }, + "required": ["Item_Code", "Item_Description", "Item_VATRate", "Line_VATAmount", "Item_NetAmount", "Line_TotalAmount", "Item_Quantity"] + }, + "refundItem": { + "type": "object", + "description": "Details of a (potentially partial) refund given for an `invoiceItem`", + "properties": { + "Refund_Quantity": { + "description": "Quantity of items refunded in this refund.", + "type": "number", + "minimum": 0 + }, + "Refund_Date": { + "description": "Date the refund was processed", + "type": "string", + "format": "date-time" + }, + "Refund_Reason": { + "description": "Freeform reason for the refund", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 100 + } + ], + "example": "Customer return." + } + } + }, + "transactionDispute": { + "type": "object", + "properties": { + "DisputeReason": { + "description": "User provided reason for disputing the payment.", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 2, + "maxLength": 250 + } + ] + } + } + }, + "account": { + "type": "object", + "description": "Details on bank account, credit card, debit card, etc.", + "properties": { + "AccountID": { + "description": "Unique account identifier", + "$ref": "api_definitions.json#/definitions/uuid" + }, + "AccountType": { + "description": "The type of account: credit card, bank account, etc.", + "$ref": "api_definitions.json#/definitions/accountType" + }, + "ClientAccountName": { + "description": "Client's friendly name for the account", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 2, + "maxLength": 150 + } + ] + }, + "NameOnAccount": { + "description": "The name on the customer's account", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/alphaSpace" }, + { + "minLength": 5, + "maxLength": 64 + } + ] + }, + "VendorID": { + "description": "ID for the bank method or card issuer", + "readOnly": true, + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 2, + "maxLength": 150 + } + ] + }, + "VendorAccountName": { + "description": "The account name defined by the account vendor", + "readOnly": true, + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 2, + "maxLength": 150 + } + ] + }, + "ReceivingAccount": { + "description": "0 = can't receive payments, 1 = can receive payments", + "type": "integer", + "enum": [ 0, 1 ], + "example": 0 + }, + "PaymentsAccount": { + "description": "0 = can't make payments, 1 = can make payments", + "type": "integer", + "enum": [ 0, 1 ], + "example": 1 + }, + "BalanceAvailable": { + "description": "0 = balance not available, 1 = balance available", + "type": "integer", + "enum": [ 0, 1 ], + "example": 0 + }, + "Balance": { + "description": "Balance (if available). Balance in pence: 100 = £1.00", + "readOnly": true, + "type": [ "null", "integer" ], + "example": 0 + }, + "IconLocation": { + "description": "Icon image for the account. Append to https://xxx.bridgepay.uk/icons/", + "readOnly": true, + "$ref": "api_definitions.json#/definitions/generalText" + }, + "UserImage": { + "$ref": "api_definitions.json#/definitions/clientImageType" + }, + "AccountNumber": { + "$ref": "api_definitions.json#/definitions/accountNumberAnon" + }, + "SortCode": { + "$ref": "api_definitions.json#/definitions/SortCodeAnon" + }, + "CardPAN": { + "$ref": "api_definitions.json#/definitions/CardPanAnon" + }, + "AccountStatus": { + "description": "Account status: 1 = Locked and can't be deleted, 2 = Deleted (but retained for historical records).", + "type": "number", + "enum": [0, 1, 2] + }, + "BillingAddress": { + "$ref": "api_definitions.json#/definitions/uuidNullable" + } + } + }, + "LoginBody": { + "type": "object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + }, + "password": { + "description": "user's password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ] + }, + "ChangePasswordBody": { + "type": "object", + "properties": { + "currentPassword": { + "description": "user's current password", + "type": "string" + }, + "newPassword": { + "description": "user's new password", + "type": "string" + } + }, + "required": [ + "currentPassword", + "newPassword" + ] + }, + "ForgotPasswordBody": { + "type": "object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + } + }, + "required": [ + "email" + ] + }, + "ResetNewPasswordBody": { + "type": "object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + }, + "validationToken": { + "description": "validationToken from the recovery email sent to the user", + "type": "string" + }, + "newPassword": { + "description": "new password for the user", + "type": "string" + } + }, + "required": [ + "email", + "validationToken", + "newPassword" + ] + }, + "RecoveryTokenBody": { + "type": "object", + "properties": { + "validationToken": { + "description": "validationToken sent out-of-band to the client", + "type": "string" + } + }, + "required": [ + "validationToken" + ] + }, + "RecoveryTokenPwBody": { + "type": "object", + "properties": { + "validationToken": { + "description": "validationToken sent out-of-band to the client", + "type": "string" + }, + "newPassword": { + "description": "new password for the client", + "type": "string" + } + }, + "required": [ + "validationToken", + "newPassword" + ] + }, + "CreateUserBody": { + "type": "object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + }, + "password": { + "description": "Users desired password", + "type": "string" + }, + "method": { + "$ref": "api_definitions.json#/definitions/loginMethod" + }, + "operator": { + "$ref": "api_definitions.json#/definitions/operatorName" + } + }, + "required": [ + "email", + "password" + ] + }, + "ConfirmEmailBody": { + "type": "object", + "properties": { + "emailValidationToken": { + "description": "Validation token value as received by email.", + "$ref": "api_definitions.json#/definitions/token" + } + }, + "required": [ + "emailValidationToken" + ] + }, + "CompleteRegistrationBody": { + "type": "object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + }, + "password": { + "description": "User's desired password", + "type": "string" + }, + "emailValidationToken": { + "description": "Validation token value as received by email.", + "$ref": "api_definitions.json#/definitions/token" + } + }, + "required": [ + "password", + "emailValidationToken" + ] + }, + "DenyEmailBody": { + "type": "object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + } + }, + "required": [ + "email" + ] + }, + "ChangeEmailBody": { + "type": "object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + } + }, + "required": [ + "email" + ] + }, + "UpdateAccountBody": { + "type": "object", + "properties": { + "ClientAccountName": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 2, + "maxLength": 150 + } + ] + }, + "BillingAddress": { + "description": "Reference to the address assocaited with the account", + "$ref": "api_definitions.json#/definitions/uuid" + }, + "Lock": { + "description": "Sets the account to be locked (can't be deleted from a mobile app), or unlocked (can be deleted)", + "type": "boolean" + } + } + }, + "AddAccountBase": { + "description": "Parameters required to add any merchant account", + "type": "object", + "properties": { + "ClientAccountName": { + "description": "Client's friendly name for the account", + "allOf": [{ + "$ref": "api_definitions.json#/definitions/generalTextSpace" + }, + { + "minLength": 2, + "maxLength": 150 + } + ] + }, + "UserImage": { + "$ref": "api_definitions.json#/definitions/clientImageType" + }, + "NameOnAccount": { + "description": "Name as it appears on the account", + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/alphaSpace" + }, + { + "minLength": 5, + "maxLength": 64 + } + ] + }, + "BillingAddress": { + "description": "Reference to the address associated with the account", + "$ref": "api_definitions.json#/definitions/uuid" + } + }, + "required": [ + "ClientAccountName", + "NameOnAccount", + "BillingAddress" + ] + }, + "AddAccountCredoraxMerchantBody": { + "description": "Parameters required to add a Credorax merchant account", + "type": "object", + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/AddAccountBase" + }, + { + "type": "object", + "properties": { + "AcquirerMerchantID": { + "description": "Credorax assigned gateway merchant ID", + "type": "string", + "pattern": "^[A-Z0-9_]*$", + "x-invalid-pattern": "[^A-Z0-9_]", + "minLength": 3, + "maxLength": 8 + }, + "AcquirerCipher": { + "description": "Credorax assigned cipher to authenticate requests", + "type": "string", + "pattern": "^[A-Za-z0-9]*$", + "x-invalid-pattern": "[^A-Za-z0-9]", + "minLength": 1, + "maxLength": 32 + } + } + } + ], + "required": [ + "ClientAccountName", + "NameOnAccount", + "AcquirerMerchantID", + "AcquirerCipher", + "BillingAddress" + ] + }, + "AddAccountWorldpayMerchantBody": { + "description": "Parameters required to add a Worldpay merchant account", + "type": "object", + "allOf": [{ + "$ref": "api_definitions.json#/definitions/AddAccountBase" + }, + { + "type": "object", + "properties": { + "AcquirerMerchantID": { + "$ref": "api_definitions.json#/definitions/WorldpayMerchantId" + }, + "AcquirerCipher": { + "$ref": "api_definitions.json#/definitions/WorldpayServiceKey" + } + } + } + ], + "required": [ + "ClientAccountName", + "NameOnAccount", + "BillingAddress", + "AcquirerMerchantID", + "AcquirerCipher" + ] + }, + "AccountIdObject": { + "type": "object", + "properties": { + "objectID": { + "description": "Id of an account", + "$ref": "api_definitions.json#/definitions/uuid" + } + }, + "required": [ "objectID" ] + }, + "device": { + "type": "object", + "properties": { + "DeviceID": { + "description": "Id of the device", + "$ref": "api_definitions.json#/definitions/uuid", + "readOnly": true + }, + "DeviceName": { + "description": "The name of the device as given by the user.", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 2, + "maxLength": 75 + } + ] + }, + "DeviceNumber": { + "$ref": "api_definitions.json#/definitions/phoneNumberAnon", + "readOnly": true + }, + "DeviceHardware": { + "$ref": "api_definitions.json#/definitions/DeviceHardware" + }, + "DeviceSoftware": { + "$ref": "api_definitions.json#/definitions/DeviceSoftware" + }, + "DeviceStatus": { + "description": "The status of the device as a bitmask. 0x01=Verified, 0x02=Authorised, 0x04=Client Suspended (e.g. lost phone), 0x08=Device Barred (can only be set/cleared by Administrator)", + "type": "number", + "readOnly": true + }, + "LastLoginLocation": { + "description": "Location of the device at last login", + "$ref": "api_definitions.json#/definitions/geojson-point" + }, + "LastLoginIP": { + "description": "IP Address of the device during the last login", + "$ref": "api_definitions.json#/definitions/ipNullable" + }, + "LastLogin": { + "description": "The last login date/time for this device", + "type": "string", + "format": "date-time" + }, + "DefaultAccount": { + "description": "The default account for this device", + "$ref": "api_definitions.json#/definitions/uuidNullable" + } + } + }, + "item": { + "type": "object", + "properties": { + "ItemID": { + "description": "Id of the version of the item", + "$ref": "api_definitions.json#/definitions/uuid", + "readOnly": true + }, + "BridgeID": { + "description": "Id of the item. Multiple objects with the same BridgeID are considered versions of the same item.", + "type": "string", + "format": "\\d{8}T\\d{9}[A-z\\d]{14}", + "example": "20160523T0938434561F0T6oy0H22C7n", + "readOnly": true + }, + "ItemStatus": { + "description": "Status of the item: 1 = active, 2 = deleted.", + "type": "integer", + "enum": [ 1, 2 ], + "example": "1", + "readOnly": true + }, + "ItemCode": { + "description": "Merchant code, UPC, etc.", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 0, + "maxLength": 50, + "default": "" + } + ] + }, + "Description": { + "description": "Freeform description of item", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "minLength": 1, + "maxLength": 150 + } + ] + }, + "Tags": { + "description": "Array of tags for this item", + "type": "array", + "items": { + "allOf": [ + { "$ref": "api_definitions.json#/definitions/fullAlphaNumericDashSpace" }, + { + "minLength": 1, + "maxLength": 20 + } + ] + } + }, + "VATCode": { + "description": "Freeform information", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" }, + { + "minLength": 0, + "maxLength": 50 + } + ] + }, + "VATRate": { + "description": "The VAT Rate of the item in 10ths of a percent. e.g. 2000 = 20%", + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "NetAmount": { + "description": "The item price excluding VAT in pence (e.g. 100 = £1.00), or NULL if using gross amounts", + "type": ["null", "integer"], + "minimum": 0 + }, + "GrossAmount": { + "description": "The item price including VAT in pence (e.g. 100 = £1.00), or NULL if using net amounts", + "type": ["null","integer"], + "minimum": 0 + }, + "ImageID": { + "description": "The image associated with this item", + "$ref": "api_definitions.json#/definitions/uuidNullable" + }, + "LoyaltyPoints": { + "description": "The loyalty points for this product (or null if not set).", + "type": [ "null", "integer" ], + "minimum": 0, + "default": null + }, + "LastUpdate": { + "description": "The date & time this item was last updated", + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": ["Description", "VATRate", "NetAmount", "GrossAmount"] + }, + "AddDeviceBody": { + "type": "object", + "properties": { + "ClientName": { + "$ref": "api_definitions.json#/definitions/email" + }, + "DeviceNumber": { + "$ref": "api_definitions.json#/definitions/phoneNumber" + }, + "Password": { + "description": "user's password", + "type": "string" + }, + "DeviceHardware":{ + "$ref": "api_definitions.json#/definitions/DeviceHardware" + }, + "DeviceSoftware":{ + "$ref": "api_definitions.json#/definitions/DeviceSoftware" + }, + "DeviceUuid": { + "$ref": "api_definitions.json#/definitions/DeviceUuid" + }, + "Location": { + "description": "Location of the device", + "$ref": "api_definitions.json#/definitions/geojson-point" + } + }, + "required": ["ClientName", "DeviceNumber", "Password", "DeviceUuid", "DeviceHardware", "DeviceSoftware"] + }, + "ReportLostBody": { + "type": "object", + "properties": { + "DeviceNumber": { + "$ref": "api_definitions.json#/definitions/phoneNumber" + } + }, + "required": ["DeviceNumber"] + }, + "pendingInvoice": { + "type": "object", + "properties": { + "InvoiceID": { + "description": "ID for this invoice", + "$ref": "api_definitions.json#/definitions/uuid", + "readOnly": true + }, + "InvoiceStatus": { + "description": "The status of the invoice", + "type": "integer", + "enum": [ 20, 21, 22 ], + "readOnly": true + }, + "CustomerDisplayName": { + "description": "The display name for the customer", + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/generalTextSpace" + }, + { + "minLength": 0, + "maxLength": 100, + "readOnly": true + } + ] + }, + "DueDate": { + "description": "The date & time this invoice comes due, or null for no due date", + "type": "string", + "format": "date-time" + }, + "MerchantInvoice": { + "description": "Array of invoiceItems or `null` if no invoice details.", + "type": [ "null", "array" ], + "items": { + "$ref": "api_definitions.json#/definitions/invoiceItem" + }, + "default": null + }, + "MerchantComment": { + "description": "Optional free-text annotation from merchant.", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "maxLength": 300 + } + ], + "example": "Invoice for service due", + "default": "" + }, + "MerchantVATNo": { + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/generalTextSpaceNullable" + }, + { + "minLength": 3, + "maxLength": 25 + } + ] + }, + "CustomerComment": { + "description": "Optional free-text annotation from customer (e.g. why they rejected the invoice).", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/generalTextSpace" }, + { + "maxLength": 300 + } + ], + "readOnly": true, + "example": "Invoice is for incorrect amount.", + "default": "" + }, + "MerchantAccountID": { + "description": "The account the merchant wants the invoice to be paid into", + "$ref": "api_definitions.json#/definitions/uuid" + }, + "RequestAmount": { + "description": "Request amount in pence (100 = £1.00). Max £250.00", + "type": "number", + "minimum": 0, + "maximum": 25000 + }, + "MerchantInvoiceNumber": { + "description": "Monotonically increasing invoice number (per Merchant)", + "type": "number", + "minimum": 0, + "readOnly": true + }, + "CreationDate": { + "description": "The date this invoice was created", + "type": "string", + "format": "datetime", + "readOnly": true + }, + "LastUpdate": { + "description": "The last time this invoice was updated", + "type": "string", + "format": "datetime", + "readOnly": true + } + }, + "required": ["MerchantAccountID", "DueDate", "RequestAmount"] + }, + "pendingInvoiceDetail": { + "allOf": [ + {"$ref": "api_definitions.json#/definitions/pendingInvoice"}, + { + "properties": { + "CustomerEmail": { + "description": "Email address of the customer to be charged (or null if the customer no longer exists)", + "$ref": "api_definitions.json#/definitions/email", + "readOnly": true + } + } + } + ] + }, + "addUpdateInvoice": { + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/pendingInvoice" + }, + { + "properties": { + "CustomerEmail": { + "description": "Email address of the customer to be charged", + "$ref": "api_definitions.json#/definitions/email" + } + }, + "required": [ + "CustomerEmail" + ] + } + ] + }, + "promoCode": { + "type": "object", + "description": "A promotional code to enable functionality", + "properties": { + "PromoCode": { + "$ref": "api_definitions.json#/definitions/uuid" + } + }, + "required": [ + "PromoCode" + ] + }, + "apiToken": { + "type": "object", + "description": "An API access token.", + "properties": { + "name": { + "description": "A memorable name for the token", + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/generalTextSpace" + }, + { + "minLength": 2, + "maxLength": 101 + } + ] + }, + "token": { + "description": "The API token (only in responses from the server)", + "readOnly": true, + "type": "string", + "pattern": "^[a-zA-Z0-9\\-_]+?\\.[a-zA-Z0-9\\-_]+?\\.([a-zA-Z0-9\\-_]+)?$" + } + }, + "required": [ + "name" + ] + }, + "successfulDeviceLoginResponse": { + "description": "Login Successful", + "type":"object", + "properties": { + "code": { + "description": "Code for further information. For first ever login this will be 10010", + "type": "integer", + "example": "10010" + }, + "info": { + "description": "Text description of the further information", + "type": "string", + "example": "First login successful" + }, + "SessionToken": { + "description": "The session token to use in future calls", + "$ref": "#/definitions/token" + }, + "DeviceName": { + "description": "The name of the device as given by the user.", + "allOf": [ + { "$ref": "#/definitions/generalTextSpace" }, + { + "minLength": 2, + "maxLength": 75 + } + ] + }, + "MerchantStatus": { + "description": "True if the client is an active merchant", + "type": "boolean", + "example": false + }, + "ServerVersion": { + "description": "The current server version.", + "$ref": "#/definitions/version" + }, + "PaymentMin": { + "description": "The minimum payment allowed for a transaction (in pence). This may be further limited by merchant configuration.", + "type": "integer", + "minimum": 0 + }, + "PaymentMax": { + "description": "The maximum payment allowed for a transaction (in pence). This may be further limited by merchant configuration.", + "type": "integer", + "minimum": 0 + }, + "TipMin": { + "description": "The minimum tip allowed for a transaction if a tip is provided (in pence). This may be further limited by merchant configuration.", + "type": "integer", + "minimum": 0 + }, + "TipMax": { + "description": "The maximum tip allowed for a transaction if a tip is provided (in pence). This may be further limited by merchant configuration.", + "type": "integer", + "minimum": 0 + }, + "TransactionMin": { + "description": "The minimum total amount (payment + tip) allowed for a full transaction (in pence). This may be further limited by merchant configuration.", + "type": "integer", + "minimum": 0 + }, + "ClientDetailsSet": { + "description": "True if the client details have been set. Otherwise they must be set before attempting to make any transactions.", + "type": "boolean" + }, + "FeatureFlags": { + "description": "List of additonal features that are enabled for this client", + "$ref": "#/definitions/featureFlags" + }, + "SessionTimeout": { + "description": "The session timeout time between messages (in milliseconds).", + "type": "integer", + "minimum": 0, + "example": 0 + }, + "PayCodeTimeout": { + "description": "The maximum length of time allowed for a paycode to be redeemed (in milliseconds).", + "type": "integer", + "minimum": 0, + "example": 0 + }, + "CallTimeout": { + "description": "The maximum length of time any request is expected to take (in milliseconds).", + "type": "integer", + "minimum": 0, + "example": 0 + }, + "PollingInterval": { + "description": "The length of time a client should leave between checking the status of a paycode (in milliseconds).", + "type": "integer", + "minimum": 0, + "example": 0 + }, + "DesyncThreshold": { + "description": "Maximum deviation between server and client clock synchronization (in milliseconds). All requests will be rejected if the client is outwith this threshold.", + "type": "integer", + "minimum": 0 + }, + "AcceptEULA": { + "description": "If present, returns the new EULA version that the user must accept to continue.", + "$ref": "#/definitions/version" + } + }, + "required": [ + "code", "info", "SessionToken", "DeviceName", "SessionTimeout", "PayCodeTimeout", + "PollingInterval", "CallTimeout", "MerchantStatus", "ServerVersion", "PaymentMin", + "PaymentMax", "TipMin", "TipMax", "TransactionMin", "ClientDetailsSet", + "DesyncThreshold", "FeatureFlags" + ] + }, + "pendingHmacResponse": { + "description": "A pending HMAC needs to be accepted before further requests can be made.", + "type": "object", + "properties": { + "PendingHMAC": { + "description": "Returns a new secure HMAC key that should be used for all future requests. The client must call `/devices/{objectID}/rotatedHMAC` to confirm acceptance. All other commands will be rejected until the HMAC Key change has been confirmed.", + "$ref": "api_definitions.json#/definitions/hex256" + } + }, + "required": [ + "PendingHMAC" + ] + } + } +} \ No newline at end of file diff --git a/node_server/swagger_api/api_error_handler.js b/node_server/swagger_api/api_error_handler.js new file mode 100644 index 0000000..6ac2418 --- /dev/null +++ b/node_server/swagger_api/api_error_handler.js @@ -0,0 +1,161 @@ +/** + * Error handlers to deal with outputing in 'application/json' etc. + * @see {@link http://expressjs.com/guide/error-handling.html#the-default-error-handler} + */ +'use strict'; +const _ = require('lodash'); +const debug = require('debug')('webconsole-api:error-handlers'); + +const config = require(global.configFile); + +// +// Define the exports +// +module.exports = { + errorHandlerMiddleware: errorHandler +}; + +/** + * If the response type is `application/json` this function formats the errors + * appropriately for that response type. Otherwise it just passes them on + * for the standard handlers to deal with. + * + * We need to do this formatting for `application/json` so that the swagger + * output validation code will not report an error on the error message itself. + * + * @param {Object} err - the error + * @param {Object} req - the request + * @param {Object} res - the response object + * @param {Callbacl} next - the callback for the next middleware in the chain + */ +function errorHandler(err, req, res, next) { + // + // Save the status code + // + const status = getStatusCode(err, res); + + if (config.isDevEnv) { + debug(err.stack); + } + + // + // Work out what format to return it in. + // We will handle json, and let the default error handler deal with + // the rest + // + const respondInJson = shouldRespondInJson(req); + if (respondInJson) { + const payload = { + info: err.message, + code: -1 + }; + + // + // Only include the stack and other properties in development + // + if (config.isDevEnv) { + // + // JSON accept type + // + const error = { + message: err.message + }; + + error.stack = err.stack; + + // + // Copy over other values except statusCode and headers which are used + // to set response headers etc. and don't need repeating in the body + // + _.merge( + error, + _.omit(err, ['statusCode', 'headers']) + ); + + // + // Add this error to the response + // + payload.error = error; + } + + // + // Report the error + // + return res.status(status).json(payload); + } else { + // + // Let anything else be handled by the defaults + // + return next(err); + } +} + +/** + * Works out the best status code to return to caller based on where the error + * comes from. + * + * @param {Object} err - The error object + * @param {Objext} res - The express response object + * @returns {number} - The status code number to respond to the client with + */ +function getStatusCode(err, res) { + let status = 500; + if (err.status) { + status = err.status; + } else if (err.statusCode) { + status = err.statusCode; + } else if (err.failedValidation && _.isString(err.message)) { + if (err.message.indexOf('Request validation failed') === 0) { + // Error from the Swagger validator regarding the Request. + // Set the status code to 400 BAD REQUEST because it is a problem + // on the client side, not the server side. + status = 400; + } else if (err.message.indexOf('Response validation failed') === 0) { + // + // It was the response validation, so that's on our side. + // + status = 500; // Internal server error + } + } else if (res.hasOwnProperty('statusCode')) { + // Something else has set a status (like the swagger router) + // so keep it for the response. + // + // WARNING: this MUST come after the failed response validation test above as + // response validation errors still have res.statusCode set to + // 200 OK despite the error. + // If this test came before that one, we would end up keeping + // the 200 OK rather than switching to a proper error. + // + status = res.statusCode; + } else { + // Unknown error - likely an exception thrown somewhere + status = 500; // Internal server error + } + + return status; +} + +/** + * Works out whether we should respond with JSON or not, based on what the client + * says and what the swagger definition defines the response as. + * + * @param {Object} req - the express request object + * @returns {boolean} - true if we should respond using JSON + */ +function shouldRespondInJson(req) { + const accept = req.headers.accept || ''; + const canAcceptJson = (accept === '*/*') || (accept.indexOf('json') !== -1); + let produces = null; // Undefined + if (req.swagger) { + if (req.swagger.operation && req.swagger.operation.produces) { + produces = req.swagger.operation.produces; + } else if (req.swagger.swaggerObject && req.swagger.swaggerObject.produces) { + produces = req.swagger.swaggerObject.produces; + } + } + const canProduceJson = + produces === null || // Assume we can unless told otherwise + (produces.indexOf('application/json') !== -1); + + return canAcceptJson && canProduceJson; +} diff --git a/node_server/swagger_api/api_expiry_middleware.js b/node_server/swagger_api/api_expiry_middleware.js new file mode 100644 index 0000000..679c07e --- /dev/null +++ b/node_server/swagger_api/api_expiry_middleware.js @@ -0,0 +1,75 @@ +// +// Middleware to handle returning the expiry time for the session. +// This will allow clients to accurately manage session keep alive requests. +// +// In a similar manner to the swagger respone validator, we replace res.end() +// with our own function, so that we will be called at the end of the response +// tree. +// etc. +// +'use strict'; +var debug = require('debug')('webconsole-api:cors-middleware'); +var sessionTimeout = require(global.pathPrefix + 'utils.js').sessionTimeout; + +module.exports = middleware; + +/** + * Define a middleware function to add the session expiry to responses, so that + * clients can manage the session keepalive effectively. + * + * @param {Object} req - the express request + * @param {Object} res - the express response + */ +function reportSessionExpiry(req, res) { + const session = req.session; + if (session && session.lastModified) { + let expiry = new Date(session.lastModified); + expiry.setMinutes(expiry.getMinutes() + sessionTimeout); + + const now = new Date(); + + const secondsLeft = Math.floor((expiry.getTime() - now.getTime()) / 1000); + + res.setHeader('X-BRIDGE-SESSION-EXPIRY', secondsLeft); + } +} + +/** + * Define a middleware function to add the session expiry to responses, so that + * clients can manage the session keepalive effectively. + * This uses a replacement for the original res.end so that we get called at + * the end as part of the response. + * + * @param {Object} req - the express request + * @param {Object} res - the express response + * @param {function} next - the callback for the next middleware in the chain + * + * @returns {any} - the result of the next() callback + */ +function middleware(req, res, next) { + // Store the original end so we can restore it later + var originalEnd = res.end; + + // Replace the end with our own function + res.end = function(data, encoding) { + // + // Put the real end back + // + res.end = originalEnd; + + // + // Add our header + // + reportSessionExpiry(req, res); + + // + // Call the original end function to continue + // + res.end(data, encoding); + }; + + // + // Call the next item on the stack + // + return next(); +} diff --git a/node_server/swagger_api/api_responses.json b/node_server/swagger_api/api_responses.json new file mode 100644 index 0000000..5f6677e --- /dev/null +++ b/node_server/swagger_api/api_responses.json @@ -0,0 +1,138 @@ +{ + "responses": { + "GeneralError": { + "description": "General error response format", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "LoginSuccess": { + "description": "Log in succeeded", + "headers": { + "set-cookie": { + "description": "Sets the session cookie (secure, http only)", + "type": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "X-XSRF-TOKEN": { + "description": "New XSRF key for the session", + "$ref": "api_definitions.json#/definitions/hex256" + }, + "displayName": { + "description": "The client's display name", + "$ref": "api_definitions.json#/definitions/alphaSpaceNullable" + }, + "newEULA": { + "description": "There is a new EULA version that the user will have to accept to continue.", + "$ref": "api_definitions.json#/definitions/version" + }, + "emailConfirmNeeded": { + "description": "This client needs to confirm their email", + "type": "boolean" + }, + "isMerchant": { + "description": "Does this client have merchant status enabled?", + "type": "boolean" + }, + "isVATRegistered": { + "description": "Is this client a merchant with a VAT number?", + "type": "boolean" + }, + "featureFlags": { + "description": "Special enabled features for this client", + "$ref": "api_definitions.json#/definitions/featureFlags" + } + }, + "required": [ "X-XSRF-TOKEN" ] + } + }, + "RecoverySuccess": { + "description": "Recovery process started", + "headers": { + "set-cookie": { + "description": "Sets the session cookie (secure, http only)", + "type": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "X-XSRF-TOKEN": { + "description": "New XSRF key for the session", + "$ref": "api_definitions.json#/definitions/hex256" + } + }, + "required": [ + "X-XSRF-TOKEN" + ] + } + }, + "Await2FA": { + "description": "Elevation request ok, but still need to wait for 2FA to complete", + "headers": { + "set-cookie": { + "description": "Sets the session cookie (secure, http only)", + "type": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "X-XSRF-TOKEN": { + "description": "New XSRF key for the session", + "$ref": "api_definitions.json#/definitions/hex256" + } + }, + "required": [ "X-XSRF-TOKEN" ] + } + }, + "LogoutSuccess": { + "description": "Successful logout", + "headers": { + "set-cookie": { + "description": "Sets the session cookie (secure, http only)", + "type": "string" + } + } + }, + "ElevationSuccess": { + "description": "Elevation succeeded", + "headers": { + "set-cookie": { + "description": "Sets the refreshed session cookie (secure, http only)", + "type": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "X-XSRF-TOKEN": { + "description": "New XSRF key for the elevated session", + "$ref": "api_definitions.json#/definitions/hex256" + } + } + } + }, + "DemotionSuccess": { + "description": "Session demoted back to normal level", + "headers": { + "set-cookie": { + "description": "Sets the refreshed session cookie (secure, http only)", + "type": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "X-XSRF-TOKEN": { + "description": "New XSRF key for the demoted session", + "$ref": "api_definitions.json#/definitions/hex256" + } + } + } + } + } +} \ No newline at end of file diff --git a/node_server/swagger_api/api_security.js b/node_server/swagger_api/api_security.js new file mode 100644 index 0000000..7a54e45 --- /dev/null +++ b/node_server/swagger_api/api_security.js @@ -0,0 +1,374 @@ +// +// This file manages the API security handling for the custom Swagger security +// definitions we have +// +'use strict'; +const debug = require('debug')('webconsole-api:security'); +const crypto = require('crypto'); + +const featureFlags = require(global.pathPrefix + '../utils/feature-flags/feature-flags.js'); + +const XSRF_HMAC_KEY = 'XwjyusBZRYVQaAhVTE77pChg'; + +// +// Session Types: Less than 0 are minimal permissions, >0 are general sessions +// that can be elevated. +// +const SESSION_TYPES = { + RECOVERY: -10, + + AWAITING_ACCEPT_EULA: -2, + AWAITING_2FA: -1, + BASIC: 0, + ELEVATED: 1 +}; + +// +// The type of session level match required. +// +const MATCH_TYPE = { + EXACT: 1, + EQUAL_OR_GREATER: 2 +}; + +// +// Level comparison results +// +const LEVEL_COMPARE_RESULT = { + SUCCESS: 0, + FAIL: -1, + NEEDS_ESCALATION: -2 +}; + +module.exports = { + bridgeSession, + elevatedBridgeSession, + awaiting2FASession: awaitingTwoFASession, + awaitingAcceptEulaSession, + recoverySession, + + generateXsrfToken, + SESSION_TYPES +}; + +// +// Callback definition for the results of the swagger security handling +// function. +// @example +// // Successful security check +// callback(); // No parameters +// @example +// // Failed security check +// var error = new Error("A text description of the error"); +// error.statusCode = 401; // If you don't want just 403 +// callback(error); +// +// @callback securityCallback +// @param {Object} err - Error object. Use a `new Error` for best results, +// and add a `statusCode` parameter if you want +// to change the default `403` HTTP response code +// @param {Object} v - Unknown and undocumented + +// +// Security function to test for the existence of a normal bridge session. +// The security function is looking for: +// - [cookie] X-BRIDGE-SESSION +// -- this contains the session token in a `Secure, HttpOnly`, session cookie +// -- used to find/confirm the user session +// -- should only be accessible to the browser so can't be read/stolen by JS +// running in the browser. +// - [header] X-XSRF-TOKEN +// -- clone of the X-XSRF-TOKEN response in the body of the login response. +// -- This ensure we are running JS (as e.g. HTML forms can't set headers. We +// will then prevent browser JS access to this server with CORS, CSP, etc. +// -- It is calculated from and can be verified using the X-BRIDGE-SESSION. +// +// Note that the above security items are largely about securing the browser +// instance against XSRF, XSS, and similar threats. They limitations on access +// to cookies and headers purely relates to what browsers //should// manage +// for code running within HTML. They are not guaranteed, and in particular +// have no bearing on what independant applications can do with HTTP responses. +// So the session management itself must also be robust and secure outside of +// these items (e.g. with session timeouts to limit risk, re-entering +// credentials before making significant changes, etc.) +// +// @param {Object} req - request object, with lots of important information +// @param {Object} def - definition of this security setting in Swagger +// @param {String} scopes - the values for the fields identified in the swagger +// definition(e.g.the value of the specified header) +// @param {integer} matchType - the type of match required for the session level +// @param {securityCallback} callback - The callback handler +// +function bridgeSessionBase(req, def, scopes, requiredLevel, matchType, callback) { + debug('bridgeSession credentials verification'); + + // + // Check that there exists at least some value for X-XSRF-TOKEN + // + if (!scopes) { + debug('- no credentials supplied'); + reportError(callback); + return; + } + + const session = req.session; + if (!session || !session.hasOwnProperty('data')) { + reportError(callback); + return; + } + + const sessionTokenValue = session.id; + if (!sessionTokenValue) { + debug('- No session id found'); + reportError(callback); + return; + } + + const email = session.data.email; + if (!email) { + debug('- No session email'); + reportError(callback); + return; + } + + // + // Check if a required feature flag is set + // + const requiredFlag = req.swagger.operation['x-feature-flag']; + if (requiredFlag && !featureFlags.isEnabled(requiredFlag, session.data)) { + reportFlagRequired(callback); + return; + } + + // + // Check if the available level is sufficient to access this request + // + const availableLevel = session.data.level; + if (availableLevel === undefined) { + debug('- No session level'); + reportError(callback); + return; + } + + const compareResult = checkAvailableLevel(availableLevel, requiredLevel, matchType); + if (compareResult === LEVEL_COMPARE_RESULT.FAIL) { + reportError(callback); + return; + } else if (compareResult === LEVEL_COMPARE_RESULT.NEEDS_ESCALATION) { + reportElevationRequired(callback); + return; + } + + // + // Re-generate the XSRF token - this uses an asynchronous stream + // + const xsrfTokenGen = generateXsrfToken(sessionTokenValue, email); + xsrfTokenGen.on('readable', () => { + // + // Get the generated token and check it matches the one we were given + // + const xsrfToken = xsrfTokenGen.read(); + if (xsrfToken === scopes) { + // Success! + debug('- Succesfully validated'); + return callback(); + } else { + debug('- Failed token comparison'); + return reportError(callback); + } + }); +} + +/** + * Compares the level of the session with the level we need, and checks whether + * we can request elevation if it is insufficient. + * + * @param {number} availableLevel - the level we have from the session + * @param {number} requiredLevel - the level this call requires + * @param {MATCH_TYPE} matchType - the type of match required + * + * @returns {LEVEL_COMPARE_RESULT} - the result of the session level comparison + */ +function checkAvailableLevel(availableLevel, requiredLevel, matchType) { + let matched = false; + + switch (matchType) { + case MATCH_TYPE.EXACT: + matched = availableLevel === requiredLevel; + break; + case MATCH_TYPE.EQUAL_OR_GREATER: + matched = availableLevel >= requiredLevel; + break; + } + + if (!matched) { + debug('- Insufficient session level: ', availableLevel, requiredLevel); + if (availableLevel < 0 || matchType === MATCH_TYPE.EXACT) { + // Just not authorised + return LEVEL_COMPARE_RESULT.FAIL; + } else { + // Can be elevated + return LEVEL_COMPARE_RESULT.NEEDS_ESCALATION; + } + } + + return LEVEL_COMPARE_RESULT.SUCCESS; +} + +// +// Security function to test for the existence of as session that is only for +// awaiting the acceptance of an updated EULA. +// +// @param {Object} req - request object, with lots of important information +// @param {Object} def - definition that we are currently being called for +// @param {String} scopes - the values for the fields identified in the swagger +// definition(e.g.the value of the specified header) +// @param {securityCallback} callback - The callback handler +// +function awaitingAcceptEulaSession(req, def, scopes, callback) { + bridgeSessionBase( + req, def, scopes, + SESSION_TYPES.AWAITING_ACCEPT_EULA, + MATCH_TYPE.EQUAL_OR_GREATER, + callback + ); +} + +// +// Security function to test for the existence of as session that is only for +// awaiting the results of a 2FA callback. +// +// @param {Object} req - request object, with lots of important information +// @param {Object} def - definition that we are currently being called for +// @param {String} scopes - the values for the fields identified in the swagger +// definition(e.g.the value of the specified header) +// @param {securityCallback} callback - The callback handler +// +function awaitingTwoFASession(req, def, scopes, callback) { + bridgeSessionBase( + req, def, scopes, + SESSION_TYPES.AWAITING_2FA, + MATCH_TYPE.EQUAL_OR_GREATER, + callback + ); +} + +// +// Security function to test for the existence of an standard (or higher) bridge session. +// +// @param {Object} req - request object, with lots of important information +// @param {Object} def - definition that we are currently being called for +// @param {String} scopes - the values for the fields identified in the swagger +// definition(e.g.the value of the specified header) +// @param {securityCallback} callback - The callback handler +// +function bridgeSession(req, def, scopes, callback) { + bridgeSessionBase( + req, def, scopes, + SESSION_TYPES.BASIC, + MATCH_TYPE.EQUAL_OR_GREATER, + callback + ); +} + +// +// Security function to test for the existence of an elevated bridge session. +// An elevated session is required to make substantive changes to an account +// +// @param {Object} req - request object, with lots of important information +// @param {Object} def - definition that we are currently being called for +// @param {String} scopes - the values for the fields identified in the swagger +// definition(e.g.the value of the specified header) +// @param {securityCallback} callback - The callback handler +// +function elevatedBridgeSession(req, def, scopes, callback) { + bridgeSessionBase( + req, def, scopes, + SESSION_TYPES.ELEVATED, + MATCH_TYPE.EQUAL_OR_GREATER, + callback + ); +} + +// +// Security function to test for the existence of an account recovery session. +// The account recovery session is required to process the recovery of an +// account based on various information. +// +// @param {Object} req - request object, with lots of important information +// @param {Object} def - definition that we are currently being called for +// @param {String} scopes - the values for the fields identified in the swagger +// definition(e.g.the value of the specified header) +// @param {securityCallback} callback - The callback handler +// +function recoverySession(req, def, scopes, callback) { + bridgeSessionBase( + req, def, scopes, + SESSION_TYPES.RECOVERY, + MATCH_TYPE.EXACT, + callback + ); +} + +// +// Generates the XSRF token as a digest of the sessionId and the email. +// @see {@link https://docs.angularjs.org/api/ng/service/$http} +// +// @example +// // Read the token asynchronously +// var xsrfTokenGen = generateXsrfToken('abcd', 'admin@example.com'); +// xsrfTokenGen.on('readable', function () { +// var token = xsrfTokenGen.read(); +// console.log('The token is: ', token); +// }); +// +// @param {String} sessionId - the current sessionId +// @param {String} email - the users email address +// +// @return {Object} - a crypto stream for the result +// +function generateXsrfToken(sessionId, email) { + const hmac = crypto.createHmac('sha256', XSRF_HMAC_KEY); + hmac.setEncoding('hex'); // avoid values invalid in cookies and/or urls + hmac.write(sessionId, 'utf8'); + hmac.end(email, 'utf8'); + + return hmac; +} + +// +// Function to return a consistent error response for failures to authenticate. +// This function is deliberately light on details so as not to leak extra +// information. +// +// @param {securityCallback} callback - The callback to use for responses +// +function reportError(callback) { + const error = new Error('Not authorised'); + error.statusCode = 401; + return callback(error); +} + +// +// Function to return a specific error when a feature fails because it needs +// an elevated session that it dosen't have +// +// @param {securityCallback} callback - The callback to use for responses +// +function reportElevationRequired(callback) { + const error = new Error('Elevated session required'); + error.statusCode = 426; + return callback(error); +} + +// +// Function to return a specific error when a path requires a feature flag but +// the user doesn't have that flag enabled. +// +// @param {securityCallback} callback - The callback to use for responses +// +function reportFlagRequired(callback) { + const error = new Error('Feature unavailable'); + error.statusCode = 403; + return callback(error); +} diff --git a/node_server/swagger_api/api_security_device.js b/node_server/swagger_api/api_security_device.js new file mode 100644 index 0000000..7a7a9f2 --- /dev/null +++ b/node_server/swagger_api/api_security_device.js @@ -0,0 +1,493 @@ +// +// This file manages the API security handling for the Device-style Swagger security +// definitions +// +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] import/max-dependencies: ["error", {"max": 11}] */ + +const config = require(global.configFile); +const debug = require('debug')('webconsole-api:security:device'); +const _ = require('lodash'); +const Ajv = require('ajv'); +const Q = require('q'); +const url = require('url'); +const JsonRefs = require('json-refs'); +const mongodb = require('mongodb'); + +const auth = require('../ComServe/auth-promises.js'); +const references = require('../utils/references.js'); +const mainDBP = require('../ComServe/mainDB-promises.js'); + +const featureFlags = require(global.pathPrefix + '../utils/feature-flags/feature-flags.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const apiUtils = require('./api_utils.js'); + +module.exports = { + deviceSession, + deviceHmacNoSession +}; + +/** + * Cache of the validation functions once we compile them from the swagger schema + */ +const VALIDATORS = { + initialised: false, + session: null, + hmac: null, + timestamp: null +}; + +/** + * Create the ajv object with the settings we want to use + */ +const AJV_OPTIONS = { + /* Return all errors, not just the first one */ + allErrors: false, + + /* Validate formats fully. Slower but more correct than 'fast' mode */ + format: 'full', + + /* Throw exceptions during schema compilation for unknown formats */ + unknownFormats: true, + + /* Don't remove additional properties, so that we can detect they exist and fail validation */ + /* If removeAdditional = true, they are removed before they can be detected as additional */ + removeAdditional: false, + + /* No defaults - all specified values must exist.*/ + useDefaults: false, + + /* Ensure all types are exactly as specified. E.g. this will not accept "1" as a number */ + coerceTypes: false +}; +const ajv = new Ajv(AJV_OPTIONS); + +/** + * Validates the security against the "device"\ security model. This requires 3 headers: + * 1. `x-bridge-device-session`: `:` + * 2. `x-bridge-hmac`: The hmac for the request as described in the wiki + * 3. `x-bridge-timestamp`: The timestamp of the packet + * + * @param {Object} req - the express request object + * @param {Object} def - the definition of the security definition we are validating + * @param {Object} scopes - the value of the header specified in the definition + * @param {Function!} callback - the callback function for success or failure or the security validation + */ +function deviceSession(req, def, scopes, callback) { + debug('DEVICE SESSION CALLED'); + + // + // Check we have valid tokens. + // + const detailsP = getSecurityValues(req, scopes).catch((error) => { + debug('Failed to get security values', error); + return Q.reject(utils.createError(30013, 'Missing or invalid security params')); + }); + + // + // Validate the client and device details + // + const validP = detailsP.then((info) => { + return validateDeviceSession(info); + }); + + // + // Initialise the session info + // + const sessionP = validP.then((sessionInfo) => { + return apiUtils.initSession(req, sessionInfo.client, sessionInfo.device); + }); + + // + // Check all the requirements passed + // + Q.all([detailsP, validP, sessionP]) + .then(() => { + // + // Everything passed so continue + // + return callback(); + }) + .catch((error) => { + // + // Something failed. Log the real error, then return a generic error (for security) + // + debug('Failed to authorise deviceSession', error); + const authFail = new Error('Not authorised'); + authFail.statusCode = 401; + return callback(authFail); + }); +} + +/** + * Validates the HMAC against the "device" security model in Login (and similar) where there is no + * session yet. This requires 2 headers: + * 1. `x-bridge-hmac`: The hmac for the request as described in the wiki + * 2. `x-bridge-timestamp`: The timestamp of the packet + * + * It also requires a path with the device specified as the objectId + * + * @param {Object} req - the express request object + * @param {Object} def - the definition of the security definition we are validating + * @param {Object} scopes - the value of the header specified in the definition + * @param {Function!} callback - the callback function for success or failure or the security validation + */ +function deviceHmacNoSession(req, def, scopes, callback) { + debug('DEVICE HMAC NO SESSION CALLED'); + + // + // Check we have valid values. + // + const detailsP = getSecurityValues(req, scopes, true).catch((error) => { + debug('Failed to get security values', error); + return Q.reject(utils.createError(30013, 'Missing or invalid security params')); + }); + + // + // Validate the client and device details + // + const validP = detailsP.then((info) => { + return validateDeviceNoSession(req, info); + }); + + // + // Initialise the session info + // + const sessionP = validP.then((sessionInfo) => { + return apiUtils.initSession(req, sessionInfo.client, sessionInfo.device); + }); + + // + // Check all the requirements passed + // + Q.all([detailsP, validP, sessionP]) + .then(() => { + // + // Everything passed so continue + // + return callback(); + }) + .catch((error) => { + // + // Something failed. Log the real error, then return a generic error (for security) + // + debug('Failed to authorise deviceHmacNoSession', error); + const authFail = new Error('Not authorised'); + authFail.statusCode = 401; + return callback(authFail); + }); +} + +/** + * Gets the values we need to check the security from the appropriate headers. + * This also validates that they are in the correct format, splits the session + * tokens, etc. + * + * @param {Object} req - the request object + * @param {string} session - the session header value + * @param {boolean} ignoreSession - true to not expect any session values + * + * @returns {Object} - Object containing the information we need for the security testing + */ +async function getSecurityValues(req, session, ignoreSession) { + if (!VALIDATORS.initialised) { + debug('Validators not initialised!'); + await initialiseValidators(); + } + + // + // Get tokens from the headers we expect + // + let deviceToken; + let sessionToken; + let sessionOk = false; + + if (ignoreSession) { + sessionOk = true; + } else { + sessionOk = VALIDATORS.session(session); + if (sessionOk === false) { + debug('Session header failed validation:', VALIDATORS.session.errors); + } else { + // + // Need to split the token into the two parts. + // NOTE: the JSON Schema validation has ensured that it is in the right format + // so we don't need to check for errors + // + [deviceToken, sessionToken] = session.split(':'); + } + } + + const hmac = req.headers['x-bridge-hmac']; + const hmacOk = VALIDATORS.hmac(hmac); + if (!hmacOk) { + debug('HMAC header failed validation:', VALIDATORS.hmac.errors); + } + + const timestamp = req.headers['x-bridge-timestamp']; + const timestampOk = VALIDATORS.timestamp(timestamp); + if (!timestampOk) { + debug('HMAC timestamp header failed validation:', VALIDATORS.timestamp.errors); + } + + // + // Check we got all 3 headers and they are formatted correctly + // + if (!sessionOk || !hmacOk || !timestampOk) { + throw new Error('Invalid headers'); + } + + // + // Get the full request address. This is a little tricky because there is no one place to get it: + // 1. The hostname comes in a header which is controlled by the caller, and is thus untrusted. + // e.g. see http://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html + // - We use our configuration value instead, which is set by us and thus trusted + // 2. The port is only included in the URL if using a non-standard port, and more importantly + // we care about the external port (at the gateway), not the internal one we are running on. + // - Again, we rely on the configuration value to include the port if neccessary + // 3. The other parts of the URL are split across multiple params in the request + // - We recombine everything in a safe way using the nodejs URL lib + // + const baseUrl = url.format({ + host: config.CCWebsiteAddress, + protocol: req.protocol, + slashes: true + }); + const fullUrl = new url.URL( + req.originalUrl, + baseUrl + ); + const address = fullUrl.toString(); + + // + // Get the raw body so we can use that as part of validating the hmac. + // This is stored in the req object by the api_body_middleware.js, and only + // exists if there was a body (and it was readable according to the given encoding). + // + const body = _.isUndefined(req.bodyRaw) ? '' : req.bodyRaw; + + // + // Get other basic values from the request + // + const method = req.method; + const featureFlag = req.swagger.operation['x-feature-flag']; + + return { + deviceToken, + sessionToken, + hmac, + timestamp, + address, + method, + body, + featureFlag + }; +} + +/** + * Initialises the validator object with compiled versions of the schemas we will + * use to validate our incoming header parameters. These schemas are held within + * the definitions section of the swagger definition. + * + * @throws {Error} - throws an error if the schema is invalid + */ +async function initialiseValidators() { + const swaggerDefs = await JsonRefs.resolveRefsAt(require.resolve('./api_definitions.json')); + const definitions = swaggerDefs.resolved.definitions; + + const sessionSchema = definitions['security.Device.sessionHeader']; + const hmacSchema = definitions['security.Device.hmacHeader']; + const timestampSchema = definitions['security.Device.hmacTimestamp']; + + const emailSchema = definitions.email; + const objectIdSchema = definitions.uuid; + + VALIDATORS.session = ajv.compile(sessionSchema); + VALIDATORS.hmac = ajv.compile(hmacSchema); + VALIDATORS.timestamp = ajv.compile(timestampSchema); + VALIDATORS.email = ajv.compile(emailSchema); + VALIDATORS.objectId = ajv.compile(objectIdSchema); + VALIDATORS.initialised = true; +} + +/** + * Function to do the validation of the session and HMAC based on the info we extracted from + * the headers. + * + * @param {Object} sessionInfo - The validated info from the request headers etc. + * @param {string} sessionInfo.deviceToken - The device token from the header + * @param {string} sessionInfo.sessionToken - The session token from the header + * @param {string} sessionInfo.hmac - The value of the HMAC header (expected HMAC) + * @param {string} sessionInfo.timestamp - The value of the HMAC timestamp header + * @param {string} sessionInfo.address - The full request url: https://example.com/p/a/t/h/?a=123 + * @param {string} sessionInfo.method - The HTTP method used for the request + * @param {string?} sessionInfo.body - The request body (if any) + * @param {string?} sessionInfo.featureFlag - The feature flag required for this feature (if any) + * + * @returns {Promise} - Promise for the successful validation (or rejected with error) + */ +async function validateDeviceSession(sessionInfo) { + /** + * Get the client details (client and device objects) + */ + const clientDetails = await auth.validateCurrentSession( + sessionInfo.deviceToken, + sessionInfo.sessionToken + ); + + /** + * Build the data in the format the legacy checkHMAC() function expects it. + * NOTE: the device and session tokens used to be implicitly included in the hmac calculation + * as they were passed in the body itself. As they are now passed as a header, we + * manually append them to the body to have the same effect of verifying that this + * request is tied to this device and session. + */ + const device = clientDetails[0]; + const client = clientDetails[1]; + + const hmacData = { + address: sessionInfo.address, + method: sessionInfo.method, + body: sessionInfo.body + sessionInfo.deviceToken + ':' + sessionInfo.sessionToken, + ClientName: client.ClientName, + timestamp: sessionInfo.timestamp, + hmac: sessionInfo.hmac + }; + + /** + * Validate the HMAC based on all the details + */ + await auth.checkHMAC( + device, + hmacData, + 'validateDeviceSession', + ); + + /** + * Validate the featureFlags exist if required + */ + const requiredFlag = sessionInfo.featureFlag; + if (requiredFlag && !featureFlags.isEnabled(requiredFlag, client)) { + debug('Required feature flag not present', requiredFlag); + throw utils.createError(30012, 'Feature unavailable'); + } + + /** + * Return the client and device for setting up the session + */ + return { + client, + device + }; +} + +/** + * Function to do the validation of the provided values and HMAC based on the + * info provided in the path, the headers, and the body. This requires: + * - hmac and timestamp from the headers + * - objectId from the path + * - ClientName from the body + * + * @param {Object} req - The express request object + * @param {Object} sessionInfo - The validated info from the request headers etc. + * @param {string} sessionInfo.hmac - The value of the HMAC header (expected HMAC) + * @param {string} sessionInfo.timestamp - The value of the HMAC timestamp header + * @param {string} sessionInfo.address - The full request url: https://example.com/p/a/t/h/?a=123 + * @param {string} sessionInfo.method - The HTTP method used for the request + * @param {string?} sessionInfo.body - The request body (if any) + * @param {string?} sessionInfo.featureFlag - The feature flag required for this feature (if any) + * + * @returns {Promise} - Promise for the successful validation (or rejected with error) + */ +async function validateDeviceNoSession(req, sessionInfo) { + /** + * Get the client details (client and device objects) + * As this is run before the main validation we have to manually validate it ourselves + */ + const deviceID = _.get(req, 'swagger.params.objectId.value'); + const deviceIDOk = VALIDATORS.objectId(deviceID); + const clientEmail = _.get(req, 'swagger.params.body.value.ClientName'); + const clientEmailOk = VALIDATORS.email(clientEmail); + + if (!deviceIDOk || !clientEmailOk) { + throw new Error('Invalid parameters'); + } + + /** + * Get the client + */ + const client = await references.getClientByEmail(clientEmail); + + /** + * Get the device (checking it belongs to our client) + */ + const device = await mainDBP.findOneObject( + mainDBP.mainDB.collectionDevice, + { + _id: mongodb.ObjectID(deviceID), + ClientID: client.ClientID + }, + undefined, // No options + true // Suppress errors (i.e. failing to find an object isn't a db connection issue) + ); + + /** + * Verify that both client and device are in a good state (validated, & not banned or blocked) + */ + const clientStatus = auth.checkClientStatus(client.ClientStatus); + const deviceStatus = auth.checkDeviceStatus(device.DeviceStatus); + + if (clientStatus !== null) { + throw clientStatus; + } + if (deviceStatus !== null) { + throw deviceStatus; + } + + /** + * Need to convert the request name to the one expected by the legacy auth functions + */ + const swaggerFunction = _.get(req, 'swagger.operation.operationId'); + let authFunctionName = swaggerFunction; + if (swaggerFunction === 'deviceLogin') { + authFunctionName = 'Login1.process'; + } + + /** + * Build the data in the format the legacy checkHMAC() function expects it. + */ + const hmacData = { + address: sessionInfo.address, + method: sessionInfo.method, + body: sessionInfo.body, + ClientName: client.ClientName, + timestamp: sessionInfo.timestamp, + hmac: sessionInfo.hmac + }; + + /** + * Validate the HMAC based on all the details + */ + await auth.checkHMAC( + device, + hmacData, + authFunctionName, + ); + + /** + * Validate the featureFlags exist if required + */ + const requiredFlag = sessionInfo.featureFlag; + if (requiredFlag && !featureFlags.isEnabled(requiredFlag, client)) { + debug('Required feature flag not present', requiredFlag); + throw utils.createError(30012, 'Feature unavailable'); + } + + /** + * Return the client and device for setting up the session + */ + return { + client, + device + }; +} diff --git a/node_server/swagger_api/api_server.js b/node_server/swagger_api/api_server.js new file mode 100644 index 0000000..4f57aa7 --- /dev/null +++ b/node_server/swagger_api/api_server.js @@ -0,0 +1,299 @@ +/* eslint-disable filenames/match-exported */ +/* eslint import/max-dependencies: ["error", {"max": 15}] */ +'use strict'; + +/** + * The core page for the configuration and deployment of the API server for + * the Web Console. + * + * The API server is powered by a Swagger API definition: + * @see {@link http://swagger.io} + * + * Express middleware is then used to take the Swagger API definition and + * handle most of the essential but repetitive parts of the API: + * - Connecting routes to handler functions + * - Checking security + * - Validating paramters + * - Validating reponses + * - Managing CORS responses + * + * In development mode there is also middleware to serve interactive API + * documentation and the API doc itself. + */ +const config = require(global.configFile); +const log = require(global.pathPrefix + 'log.js'); +const sessionTimeout = require(global.pathPrefix + 'utils.js').sessionTimeout; +const _ = require('lodash'); +const express = require('express'); +const compression = require('compression'); +const session = require('express-session'); +const morgan = require('morgan'); // Logging middleware by expressjs +const MongoStore = require('connect-mongo')(session); +const swaggerTools = require('swagger-tools'); +const RateLimit = require('express-rate-limit'); + +const router = express.Router(); +const corsMiddleware = require('./api_cors_middleware.js'); +const security = require('./api_security.js'); +const securityDevice = require('./api_security_device.js'); +const errorHandler = require('./api_error_handler.js'); +const expiryMiddleware = require('./api_expiry_middleware'); +const bodyParserMiddleware = require('./api_body_middleware.js'); + +const initMorgan = require(global.pathPrefix + '../utils/init_morgan.js'); +const JsonRefs = require('json-refs'); + +// +// Export the router +// +module.exports = initWebConsoleApi; + +// +// Swagger Router configuration +// @see {@link https://github.com/apigee-127/swagger-tools/blob/master/docs/Middleware.md#swagger-router} +// +const swaggerRouterOptions = { + // @member {String} - path to the controllers + controllers: global.rootPath + 'swagger_api/controllers', + + // @member {Boolean} - enable autogenerated stubs for dev environment + useStubs: config.isDevEnv +}; + +// +// Swagger Validator configuration options +// @see {@link https://github.com/apigee-127/swagger-tools/blob/master/docs/Middleware.md#swagger-validator} +// +const swaggerValidatorOptions = { + // @member{Boolean} - validate responses as well as requests + // swagger stubs don't match the validation entirely, so responses can't + // be validated if they are enabled. + validateResponse: Boolean(swaggerRouterOptions.useStubs) +}; + +/** + * Function to intialise the swagger tools for serving the swagger-based + * web console API. This also uses express-session persisted in the mongo + * database, so requires the connection parameters to be passed through. + * + * @param {string} mongoConnectString - mongo db connect string + * @param {Object} mongoOpts - mongo db connect options + * @param {string} collection - the collection for persisting sessions + * + * @returns {Object} - router with middleware included + */ +async function initWebConsoleApi(mongoConnectString, mongoOpts, collection) { + try { + // + // Resolve any external references in the swagger file. + // + const resolved = await JsonRefs.resolveRefsAt(require.resolve('./api_swagger_def.json')); + const swaggerDoc = resolved.resolved; + + // + // We are going to be used as an express router under /api so remove that from + // the front of the base path in the swagger API definition. If we don't + // remove it we end up with a path of /api/api/v0/... + // + swaggerDoc.basePath = swaggerDoc.basePath.replace('/api', ''); + + // + // Set up the retry behaviour so that it is more manageable in most cases. + // This should be updated in the long run, but for now we are just + // mitigating the issues. For more details see: + // Task: {T580} Express Session + Connect Mongo will fail forever if + // Database offline for >30s (or longer after mitigation) + // {@link http://10.0.10.242/T580} + // + let opts = _.clone(mongoOpts); + opts = _.merge({}, opts, { + autoReconnect: true, // Enable reconnecting in the driver + reconnectTries: 1000, // Retry connection 1000 times + reconnectInterval: 1000, // 1000 ms (1s) between retries + bufferMaxEntries: 0 // Don't cache queries on failure + }); + + // + // Create the persistent session store + // @see {@link https://github.com/kcbanner/connect-mongo} + // + const store = new MongoStore({ + url: mongoConnectString, + mongoOptions: opts, + ttl: sessionTimeout * 60, // Convert to seconds + autoRemove: 'ignore', // Use a TTL index in mongo db to delete + touchAfter: 60, // Only update the session token every 1 min + collection + }); + + // Catch errors + store.on('error', (error) => { + log.system( + 'ERROR', + ('Error connecting to Session database. ' + error), + 'MongoDbStore', + '', + 'System', + '127.0.0.1'); + }); + + // + // Session handling configuration + // @see {@link https://github.com/expressjs/session} + // + const cookieName = swaggerDoc.securityDefinitions.bridge_session['x-session-cookie']; + const sessionOptions = { + name: cookieName, // Cookie name + secret: config.webconsole.cookieSecret, // Cookie secret key + cookie: { + path: '/api', // Only applies to the API path + httpOnly: true, // Not accessible by javascript running on the page + secure: true, // Only available over HTTPS + maxAge: null // Session cookie + }, + resave: false, // Don't resave if nothing changes + rolling: false, // We'll manage session timeout ourselves + saveUninitialized: false, // Only use sessions for logged in users + unset: 'destroy', // Delete the session storage when it is cleared + store // Persistent session storage to MongoDb + }; + + // + // Initialise the morgan format + // + initMorgan.init(); + + // + // Rate limiting options + // Warning: we must clone the value from config so that when we change the + // keyGenerator etc. it doesn't affect other places using the same + // config. + // + const rateLimitConfig = _.clone(config.rateLimits.api); + rateLimitConfig.keyGenerator = function(req) { + // + // Limit per-client if we know who the client is, or by IP if we don't + // + if (req.session && req.session.data) { + return req.session.data.clientID; + } else { + return req.ip; + } + }; + rateLimitConfig.handler = function(req, res) { + // Always send a JSON response + res.status(rateLimitConfig.statusCode).json({ + code: 30500, + info: 'Rate limit reached. Please wait and try again' + }); + }; + const limiter = new RateLimit(rateLimitConfig); + + // + // Initialize the Swagger middleware from the Swagger API definition. + // This is asynchronous so we need to wait until its done before configuring + // all the express middleware we will use for managing the API + // + swaggerTools.initializeMiddleware(swaggerDoc, (middleware) => { + // + // Compression middleware + // + router.use(compression()); + + // + // Custom body-parser to store the raw body as well as the parsed JSON body. + // Swagger tools automatically uses the rsults of this rather than its default parser. + // + router.use(bodyParserMiddleware.bridgeBodyParser()); + + // + // Logging middleware + // + router.use(morgan('bridge-combined')); + + // + // Middleware to interpret Swagger resources and attach metadata to request + // - must be first in swagger - tools middleware chain + // + router.use(middleware.swaggerMetadata()); + + // + // Enable session handling + // + router.use(session(sessionOptions)); + + /* + * Rate Limiting + */ + router.use(limiter); + + // + // Cors middleware + // + router.use(corsMiddleware()); + + // + // Session expiry reporting middleware + // + router.use(expiryMiddleware); + + // + // Middleware to enforce the security rules definedin the Swagger file. + // Ignore lack of camel case for the swagger defines: + router.use(middleware.swaggerSecurity({ + awaiting_accept_eula_bridge_session: security.awaitingAcceptEulaSession, + awaiting_2fa_bridge_session: security.awaiting2FASession, + bridge_session: security.bridgeSession, + elevated_bridge_session: security.elevatedBridgeSession, + recovery_session: security.recoverySession, + device_session: securityDevice.deviceSession, + device_hmac_nosession: securityDevice.deviceHmacNoSession + })); + + // + // Middleware to validate Swagger request and response parameters + // + router.use(middleware.swaggerValidator(swaggerValidatorOptions)); + + // + // Middleware to route validated requests to the appropriate controller + // + router.use(middleware.swaggerRouter(swaggerRouterOptions)); + + // + // Middleware to serve the Swagger documents and Swagger UI. + // This provides access to the Swagger UI at /api/docs and the full + // swagger json file at /api/api-docs + // Note: only enabled in development environments + // + if (config.isDevEnv) { + router.use(middleware.swaggerUi()); + } + + // + // Error handler middleware to correct server errors as JSON if needed + // + router.use(errorHandler.errorHandlerMiddleware); + + // + // Stop any requests that didn't get handled above going any further. + // This only applies to requests under this router, so no other part of + // server could handle it. + // + router.use((req, res) => { + res.status(404).json({ + code: 30000, + info: 'API path not found' + }); + }); + }); + + return router; + } catch (error) { + // Failed to retreive swagger references + // eslint-disable-next-line no-console + console.log('Failed to read the swagger definition files: ' + error.toString()); + return null; + } +} + diff --git a/node_server/swagger_api/api_swagger_def.json b/node_server/swagger_api/api_swagger_def.json new file mode 100644 index 0000000..4abcd8f --- /dev/null +++ b/node_server/swagger_api/api_swagger_def.json @@ -0,0 +1,2551 @@ +{ + "swagger": "2.0", + "info": { + "version": "0.4.1", + "title": "Web Dashboard API" + }, + "basePath": "/api/v0", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "security": [ + { + "bridge_session": [ ] + }, + { + "device_session": [ ] + } + ], + "tags": [ + { + "name": "login", + "description": "Management of login, and logout" + }, + { + "name": "users", + "description": "User registration, details and information" + }, + { + "name": "transactions", + "description": "Transaction information" + }, + { + "name": "accounts", + "description": "Client accounts" + }, + { + "name": "merchant", + "description": "Merchant related functions" + }, + { + "name": "devices", + "description": "Mobile devices using the payment app" + }, + { + "name": "utils", + "description": "General requests to support clients etc." + } + ], + "paths": { + "/login": { + "x-swagger-router-controller": "api_login_controller", + "post": { + "tags": [ "login" ], + "summary": "Login", + "description": "Username & password log in to a new session. On succesfull login, the server replies with 200 OK, a `Secure, HttpOnly` session cookie, and an XSRF token for this session in the body. From then on the client should include in any request:\n* the session cookie (generally using XHR with the `withCredentials` flag), and\n* a custom header - `X-XSRF-TOKEN` - that reflects the XSRF token back to the server.\n", + "operationId": "login", + "security": [ ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Credentials", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/LoginBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "$ref": "api_responses.json#/responses/LoginSuccess" + }, + "401": { + "description": "Email or password didn't match. Please try again", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "403": { + "description": "User is barred. Contact provider for help.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/poll2FA": { + "x-swagger-router-controller": "api_login_controller", + "post": { + "tags": [ "login" ], + "summary": "Polls the 2FA status", + "description": "Polls the 2FA status to check if the 2-factor request has been authorised (or timed out). 2FA can only be authorised by the apps.", + "operationId": "poll2FA", + "security": [ { "awaiting_2fa_bridge_session": [ ] } ], + "parameters": [], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "$ref": "api_responses.json#/responses/LoginSuccess" + }, + "202": { + "description": "2-factor request is still pending" + }, + "408": { + "description": "2-factor request is invalid, has timed out or been rejected. Must start again from /login." + } + } + } + }, + "/logout": { + "x-swagger-router-controller": "api_login_controller", + "post": { + "tags": [ "login" ], + "operationId": "logout", + "summary": "Logout", + "security": [ + { + "awaiting_accept_eula_bridge_session": [] + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "$ref": "api_responses.json#/responses/LogoutSuccess" + } + } + } + }, + "/login/elevate": { + "x-swagger-router-controller": "api_login_controller", + "post": { + "tags": [ "login" ], + "summary": "Elevate standard session", + "description": "Elevates the existing session to allow the user to make more significant changes (which can't be done from a standard session). All session cookies and tokens are refreshed by the elevation for more security.", + "operationId": "elevate", + "parameters": [], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "$ref": "api_responses.json#/responses/ElevationSuccess" + }, + "202": { + "$ref": "api_responses.json#/responses/Await2FA" + }, + "401": { + "description": "Email or password didn't match. Please try again", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "409": { + "description": "Client doesn't have any active devices that can process the required 2FA request. Add a new device, or contact the service provider.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/login/demote": { + "x-swagger-router-controller": "api_login_controller", + "post": { + "tags": [ "login" ], + "summary": "Demote elevated session", + "description": "Demotes the existing session back to the standard level that doesn't allow significant changes. All session cookies and tokens are refreshed for more security.", + "operationId": "demote", + "security": [ { "elevated_bridge_session": [ ] } ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "$ref": "api_responses.json#/responses/DemotionSuccess" + } + } + } + }, + "/login/accept-eula": { + "x-swagger-router-controller": "api_login_controller", + "post": { + "tags": [ "login" ], + "summary": "Accept EULA version", + "description": "Reports client acceptance of the EULA version specified", + "operationId": "acceptEULA", + "security": [ + { + "awaiting_accept_eula_bridge_session": [] + } + ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Accepted EULA version", + "required": true, + "schema": { + "type": "object", + "properties": { + "acceptedVersion": { + "$ref": "api_definitions.json#/definitions/version" + } + } + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "$ref": "api_responses.json#/responses/LoginSuccess" + } + } + } + }, + "/keepalive": { + "x-swagger-router-controller": "api_login_controller", + "get": { + "tags":[ "login" ], + "operationId": "keepAlive", + "summary": "Extend the session duration", + "description": "Extends the lifetime of the session (assuming the session is currently valid. Does nothing else", + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Successful" + } + } + } + }, + "/recovery": { + "x-swagger-router-controller": "api_recovery_controller", + "post": { + "tags": [ + "login" + ], + "summary": "Start account recovery", + "description": "Starts account recovery for the specified email address. This will create a session in which all further steps must be completed.", + "operationId": "startRecovery", + "security": [], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Email address of the account to recover", + "required": true, + "schema": { + "type":"object", + "properties": { + "email": { + "$ref": "api_definitions.json#/definitions/email" + } + } + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "$ref": "api_responses.json#/responses/RecoverySuccess" + }, + "202": { + "$ref": "api_responses.json#/responses/RecoverySuccess" + }, + "429": { + "description": "Too many requests in too short a time. Please wait and try again", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/recovery/emailpw": { + "x-swagger-router-controller": "api_recovery_controller", + "post": { + "tags": [ + "login" + ], + "summary": "Reset the password to recover the account", + "description": "Confirms the email token and resets the password", + "operationId": "completeRecoveryEmailPw", + "security": [{ + "recovery_session": [] + }], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Recovery details", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/RecoveryTokenPwBody" + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Recovery completed succcessfully. Login with the new credentials." + } + } + } + }, + "/recovery/email": { + "x-swagger-router-controller": "api_recovery_controller", + "post": { + "tags": [ + "login" + ], + "summary": "Comfirm email address", + "description": "Confirms the email address using the token sent to that address. Receives a variable-length list of KBA questions to ask in response", + "operationId": "confirmRecoveryEmail", + "security": [ + { + "recovery_session": [] + } + ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Recovery details", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/RecoveryTokenBody" + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "202": { + "description": "Email validation successfull. Respond to questions to continue.", + "schema": { + "type": "object", + "properties": { + "Questions": { + "description": "Array of questions to ask the user.", + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/question" + } + } + } + } + } + } + } + }, + "/recovery/answers": { + "x-swagger-router-controller": "api_recovery_controller", + "post": { + "tags": [ + "login" + ], + "summary": "Presents answers to the requested questions", + "description": "Gives answers to the requested questions + provide a registered device number", + "operationId": "confirmAnswers", + "security": [ + { + "recovery_session": [] + } + ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Answers to the questions", + "required": true, + "schema": { + "type": "object", + "properties": { + "Answers": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/answer" + } + }, + "DeviceNumber":{ + "$ref": "api_definitions.json#/definitions/phoneNumber" + } + } + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Answers accepted, and recovery token sent to device by SMS." + } + } + } + }, + "/recovery/devicepw": { + "x-swagger-router-controller": "api_recovery_controller", + "post": { + "tags": [ + "login" + ], + "summary": "Reset the password to recover the account", + "description": "Confirms the device token and resets the password", + "operationId": "completeRecoveryDevicePw", + "security": [ + { + "recovery_session": [] + } + ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Recovery details", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/RecoveryTokenPwBody" + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Recovery completed succcessfully. Login with the new credentials." + } + } + } + }, + "/utils/version": { + "x-swagger-router-controller": "api_utils_controller", + "get": { + "tags": ["utils"], + "description": "Gets the version of the server", + "operationId": "getVersions", + "summary": "Gets the version of the server", + "security": [], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Object containing all relevant versions", + "schema": { + "type": "object", + "properties": { + "ServerVersion": { + "$ref": "api_definitions.json#/definitions/version" + } + } + } + } + } + } + }, + "/users": { + "x-swagger-router-controller": "api_users_controller", + "get": { + "tags": [ "users" ], + "description": "List all users", + "operationId": "getUsers", + "summary": "List users", + "security": [ { "administrator_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/limitParam" }, + { "$ref": "#/parameters/skipParam" }, + { "$ref": "#/parameters/minDateParam" }, + { "$ref": "#/parameters/maxDateParam" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Users listed", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/User" + } + } + } + } + }, + "post": { + "tags": [ "users" ], + "operationId": "createUser", + "security": [ ], + "summary": "Add user", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Request body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/CreateUserBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "201": { + "description": "User successfully created (no body)" + }, + "409" : { + "description": "Email address already in use", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/users/change-password": { + "x-swagger-router-controller": "api_users_controller", + "post": { + "tags": [ "users" ], + "summary": "Change your password", + "description": "Allows a user to change their password when they still know their current password. For forgotten passwords, follow the /users/forgot-password flow", + "operationId": "changePassword", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Change Password Body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/ChangePasswordBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully changed the password.", + "headers": { + "set-cookie": { + "description": "Sets the session cookie (secure, http only) for the new session", + "type": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "X-XSRF-TOKEN": { + "description": "New XSRF key for the basic session", + "$ref": "api_definitions.json#/definitions/hex256" + } + } + } + } + } + } + }, + "/users/forgot-password": { + "x-swagger-router-controller": "api_user_controller", + "post": { + "tags": [ "users" ], + "summary": "Start a password reset", + "description": "Starts the forgot password flow", + "operationId": "forgotPassword", + "security": [ ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Forgot Password Body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/ForgotPasswordBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Questions for the next step.", + "headers": { + "set-cookie": { + "description": "Sets the session cookie (secure, http only) for the forgot password flow", + "type": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "X-XSRF-TOKEN": { + "description": "New XSRF key for the basic session", + "$ref": "api_definitions.json#/definitions/hex256" + }, + "questions": { + "description": "Array of questions to ask the user.", + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/question" + } + } + } + } + } + } + } + }, + "/users/forgot-password/verify-credentials": { + "x-swagger-router-controller": "api_user_controller", + "post": { + "tags": [ "users" ], + "summary": "Verify user credentials", + "description": "Provides answers to the questions returned by /users/forgot-password", + "operationId": "verifyCredentials", + "security": [ { "reset_password_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Answers to the questions", + "required": true, + "schema": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/answer" + } + } + } + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Email sent with reset password code. Progress to password reset form." + }, + "401": { + "description": "Answers don't match the stored data. The user can correct their answers and re-submit." + } + } + } + }, + "/users/forgot-password/resend-token": { + "x-swagger-router-controller": "api_user_controller", + "post": { + "tags": [ "users" ], + "summary": "Resend reset token", + "description": "Requests a resend of the password reset verification token. This will also invalidate the previous token.", + "operationId": "resendToken", + "security": [ { "reset_password_bridge_session": [ ] } ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Email resent with token" + }, + "403": { + "description": "No password reset session exists. A new reset session should be started if required." + } + } + } + }, + "/users/forgot-password/reset-password": { + "x-swagger-router-controller": "api_user_controller", + "post": { + "tags": [ "users" ], + "summary": "Reset password", + "description": "Allows the user to reset their password using the token from the recovery email", + "operationId": "resetPassword", + "security": [ { "reset_password_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Reset Password Body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/ResetNewPasswordBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Password reset. User must now login again." + }, + "401": { + "description": "The email and password recovery token do not match. The user can re-enter and try again.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/users/resend-confirm-email": { + "x-swagger-router-controller": "api_users_controller", + "post": { + "tags": [ "users" ], + "operationId": "resendConfirmEmail", + "summary": "Resend the email address confirmation email", + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Email resent" + } + } + } + }, + "/users/confirm-email": { + "x-swagger-router-controller": "api_users_controller", + "post": { + "tags": [ "users" ], + "operationId": "confirmEmail", + "summary": "Confirm email address", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Request body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/ConfirmEmailBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Email confirmed" + } + } + } + }, + "/users/complete-registration": { + "x-swagger-router-controller": "api_users_controller", + "post": { + "tags": [ + "users" + ], + "security": [], + "operationId": "completeRegistration", + "summary": "Completes a partial registration previously added via the integration API", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Request body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/CompleteRegistrationBody" + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Registration Complete" + } + } + } + }, + "/users/deny-email": { + "x-swagger-router-controller": "api_users_controller", + "post": { + "tags": [ "users" ], + "operationId": "denyEmail", + "summary": "Deny email address", + "description": "Allow someone to deny that they signed up for an account with this address. E.g. if someone else entered the wrong address.", + "security": [ ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Request body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/DenyEmailBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Email confirmation rejected" + } + } + } + }, + "/users/me": { + "x-swagger-router-controller": "api_users_controller", + "get": { + "tags": [ "users" ], + "operationId": "getUser", + "summary": "Get user", + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "The user info", + "schema": { + "$ref": "api_definitions.json#/definitions/User" + } + } + } + }, + "post": { + "tags": [ "users" ], + "operationId": "updateUser", + "summary": "Update user", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/User" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Details updated" + } + } + } + }, + "/users/me/email": { + "x-swagger-router-controller": "api_users_controller", + "put": { + "tags": [ + "users" + ], + "operationId": "changeEmail", + "summary": "Change Email address", + "security": [ + { + "elevated_bridge_session": [] + } + ], + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/ChangeEmailBody" + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Details updated" + } + } + }, + "delete": { + "tags": [ + "users" + ], + "operationId": "revertChangedEmail", + "summary": "Revert an attempt to change the Email address (no login required)", + "security": [], + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/ConfirmEmailBody" + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Details updated" + } + } + } + }, + "/users/me/kyc": { + "x-swagger-router-controller": "api_users_controller", + "get": { + "tags": [ "users" ], + "operationId": "getKYC", + "summary": "Get Client KYC", + "description": "Gets the Know Your Customer (KYC) details for this client.", + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "The KYC data for this client", + "schema": { + "$ref": "api_definitions.json#/definitions/kyc" + } + } + } + }, + "put": { + "tags": [ "users" ], + "operationId": "updateKYC", + "summary": "Update client KYC", + "description": "Updates the Know Your Customer (KYC) details for this client.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/kyc" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "$ref": "api_responses.json#/responses/GeneralError" + } + } + } + }, + "/users/me/merchant": { + "x-swagger-router-controller": "api_users_controller", + "get": { + "tags": [ "users" ], + "operationId": "getMerchant", + "summary": "Get client's company details", + "description": "Gets the details about the client's company.", + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "The merchant details for this client", + "schema": { + "$ref": "api_definitions.json#/definitions/merchant" + } + } + } + }, + "put": { + "tags": [ "users" ], + "operationId": "updateMerchant", + "summary": "Update client's company details.", + "description": "Updates the merchant details for this client (authorised merchants only).", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/merchant" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Merchant details added." + } + } + } + }, + "/users/me/merchant-promo-code": { + "x-swagger-router-controller": "api_merchant_controller", + "post": { + "tags": [ + "users" + ], + "operationId": "addMerchantPromoCode", + "summary": "Add a merchant promotion code.", + "description": "Enables merchant status if provided with a valid merchant promotion code.", + "security": [ + { + "elevated_bridge_session": [] + } + ], + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/promoCode" + } + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Merchant status enabled." + } + } + } + }, + "/users/me/merchant/tokens": { + "x-swagger-router-controller": "api_tokens_controller", + "get": { + "tags": [ + "utils" + ], + "operationId": "listTokens", + "summary": "List access tokens.", + "description": "Lists all the Integrations API access tokens configured for this merchant (authorised merchants only).", + "security": [ + { + "elevated_bridge_session": [] + } + ], + "x-feature-flag": "tokens", + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Tokens List.", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/apiToken" + } + } + } + } + }, + "post": { + "tags": ["utils"], + "operationId": "createToken", + "summary": "Create an access token.", + "description": "Creates an access token for 3rd party access to the services (authorised merchants only).", + "security": [{ + "elevated_bridge_session": [] + }], + "x-feature-flag": "tokens", + "parameters": [{ + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/apiToken" + } + }], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Token created.", + "schema": { + "type": "object", + "properties": { + "token": { + "description": "The new token. This must be saved as its not available again from the system.", + "type":"string" + } + } + } + } + } + } + }, + "/users/me/merchant/tokens/{token}": { + "x-swagger-router-controller": "api_tokens_controller", + "delete": { + "tags": [ + "utils" + ], + "operationId": "deleteToken", + "summary": "Delete access token.", + "description": "Deletes an access token, preventing it from being used in any future integrations API reqiests.", + "security": [{ + "elevated_bridge_session": [] + }], + "x-feature-flag": "tokens", + "parameters": [{ + "name": "token", + "in": "path", + "description": "Token to delete", + "required": true, + "type": "string", + "pattern": "^[a-zA-Z0-9\\-_]+?\\.[a-zA-Z0-9\\-_]+?\\.([a-zA-Z0-9\\-_]+)?$" + }], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Token deleted." + } + } + } + }, + "/transactions": { + "x-swagger-router-controller": "api_transactions_controller", + "get": { + "tags": [ "transactions" ], + "operationId": "getTransactions", + "summary": "List transactions", + "description": "This command returns a list of transactions for the current user", + "parameters": [ + { "$ref": "#/parameters/limitParam" }, + { "$ref": "#/parameters/skipParam" }, + { "$ref": "#/parameters/minDateParam" }, + { "$ref": "#/parameters/maxDateParam" }, + { + "name": "transactionTypes", + "description": "The type(s) of transaction to return. See Transaction for the meaning of the values.", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "integer", + "enum": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] + } + }, + { + "name": "accountId", + "description": "Return only transactions associated with this account", + "in": "query", + "required": false, + "type": "string", + "pattern": "^([a-z0-9]{24})$", + "minLength": 24, + "maxLength": 24 + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful listing", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/transaction" + } + } + } + } + } + }, + "/transactions/{objectId}": { + "x-swagger-router-controller": "api_transactions_controller", + "get": { + "tags": [ "transactions" ], + "operationId": "getTransaction", + "summary": "Transaction detail", + "description": "The detailed information of a single transaction", + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Transaction found", + "schema": { + "$ref": "api_definitions.json#/definitions/transactionDetail" + } + } + } + } + }, + "/transactions/{objectId}/refund": { + "x-swagger-router-controller": "api_transactions_controller", + "post": { + "tags": [ "transactions" ], + "operationId": "refundTransaction", + "summary": "Refund transaction", + "description": "Refunds this whole transaction. Can only be initiated by the merchant side of the transaction.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Transaction refunded" + } + } + } + }, + "/transactions/{objectId}/dispute": { + "x-swagger-router-controller": "api_transactions_controller", + "post": { + "tags": [ "transactions" ], + "operationId": "disputeTransaction", + "summary": "Dispute transaction", + "description": "Flags a dispute with this transaction (e.g. incorrect amount, suspected fraud, etc.). This may only be initiated by the customer side of the transaction.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/transactionDispute" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Transaction dispute added" + } + } + } + }, + "/transactions/{objectId}/cancel-dispute": { + "x-swagger-router-controller": "api_transactions_controller", + "post": { + "tags": [ "transactions" ], + "operationId": "cancelDisputeTransaction", + "summary": "Cancel dispute.", + "description": "Removes the dispute request from this transaction", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Transaction dispute removed" + } + } + } + }, + "/accounts": { + "x-swagger-router-controller": "api_accounts_controller", + "get": { + "tags": [ "accounts" ], + "operationId": "getAccounts", + "summary": "List accounts", + "description": "This command returns a list of accounts for the current client", + "parameters": [ + { "$ref": "#/parameters/limitParam" }, + { "$ref": "#/parameters/skipParam" }, + { "$ref": "#/parameters/minDateParam" }, + { "$ref": "#/parameters/maxDateParam" }, + { + "name": "includeDeleted", + "in": "query", + "description": "Set to true if the query should also return deleted accounts, otherwise they are not included.", + "required": false, + "default": false, + "type": "boolean" + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful listing", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/account" + } + } + } + } + } + }, + "/accounts/add/credorax": { + "x-swagger-router-controller": "api_accounts_controller", + "post": { + "tags": [ "accounts" ], + "operationId": "addAccountCredorax", + "summary": "Add a Credorax merchant account", + "description": "Adds a Credorax merchant account into the client's account list. Note that this is only valid for client's with merchant status enabled.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/AddAccountCredoraxMerchantBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "201": { + "description": "Successfully added the new account", + "schema": { + "type": "object", + "description": "The id of the new account", + "properties": { + "id": { + "$ref": "api_definitions.json#/definitions/uuid" + } + } + } + } + } + } + }, + "/accounts/add/worldpay": { + "x-swagger-router-controller": "api_accounts_controller", + "post": { + "tags": ["accounts"], + "operationId": "addAccountWorldpay", + "summary": "Add a Worldpay merchant account", + "description": "Adds a Worldpay merchant account into the client's account list. Note that this is only valid for client's with merchant status enabled.", + "security": [{ + "elevated_bridge_session": [] + }], + "parameters": [{ + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/AddAccountWorldpayMerchantBody" + } + }], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "201": { + "description": "Successfully added the new account", + "schema": { + "type": "object", + "description": "The id of the new account", + "properties": { + "id": { + "$ref": "api_definitions.json#/definitions/uuid" + } + } + } + } + } + } + }, + "/accounts/add/demo": { + "x-swagger-router-controller": "api_accounts_controller", + "post": { + "tags": ["accounts"], + "operationId": "addAccountDemo", + "summary": "Add a Demo merchant account", + "description": "Adds a Demo merchant account into the client's account list. Note that this is only valid for client's with merchant status enabled.", + "security": [{ + "elevated_bridge_session": [] + }], + "parameters": [{ + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/AddAccountBase" + } + }], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "201": { + "description": "Successfully added the new account", + "schema": { + "type": "object", + "description": "The id of the new account", + "properties": { + "id": { + "$ref": "api_definitions.json#/definitions/uuid" + } + } + } + } + } + } + }, + "/accounts/{objectId}": { + "x-swagger-router-controller": "api_accounts_controller", + "get": { + "tags": [ "accounts" ], + "operationId": "getAccount", + "summary": "Account details", + "description": "This command returns more details on the specified account", + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful", + "schema": { + "$ref": "api_definitions.json#/definitions/account" + } + } + } + }, + "post": { + "tags": [ "accounts" ], + "operationId": "updateAccount", + "summary": "Update account", + "description": "Updates editable parameters of an account. NOTE: For more extensive changes, create a new account.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/UpdateAccountBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful update" + } + } + }, + "delete": { + "tags": [ "accounts" ], + "operationId": "deleteAccount", + "summary": "Delete Account", + "description": "Deletes an account", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully deleted" + } + } + } + }, + "/addresses": { + "x-swagger-router-controller": "api_addresses_controller", + "get": { + "tags": [ "accounts" ], + "operationId": "getAddresses", + "summary": "List addresses", + "description": "This command returns a list of addresses for the current client", + "parameters": [ + { "$ref": "#/parameters/limitParam" }, + { "$ref": "#/parameters/skipParam" }, + { "$ref": "#/parameters/minDateParam" }, + { "$ref": "#/parameters/maxDateParam" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful listing", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/address" + } + } + } + } + }, + "post": { + "tags": [ "accounts" ], + "operationId": "addAddress", + "summary": "Add address", + "description": "Add a new address. The parameter type depends on the address type being created", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/address" + }, + { + "required": [ "AddressDescription", "Address1", "Town", "PostCode", "Country" ] + } + ] + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "201": { + "description": "Successfully added the new address", + "schema": { + "type": "object", + "description": "The id of the new address", + "properties": { + "AddressID": { + "$ref": "api_definitions.json#/definitions/uuid" + } + } + } + } + } + } + }, + "/addresses/{objectId}": { + "x-swagger-router-controller": "api_addresses_controller", + "get": { + "tags": [ "accounts" ], + "operationId": "getAddress", + "summary": "Address details", + "description": "This command returns the specified address", + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful", + "schema": { + "$ref": "api_definitions.json#/definitions/address" + } + } + } + }, + "delete": { + "tags": [ "accounts" ], + "operationId": "deleteAddress", + "summary": "Delete Address", + "description": "Deletes an address", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully deleted" + } + } + } + }, + "/items": { + "x-swagger-router-controller": "api_items_controller", + "get": { + "tags": [ "merchant" ], + "operationId": "getItems", + "summary": "List items", + "description": "This command returns a list of items for the current client", + "parameters": [ + { + "name": "includeDeleted", + "in": "query", + "description": "true to include deleted items as well as activeones.", + "required": false, + "type": "boolean", + "default": false + }, + { + "name": "BridgeID", + "in": "query", + "description": "Limit the returned items to only ones that match the BridgeID", + "required": false, + "type": "string", + "pattern": "\\d{8}T\\d{9}[A-z\\d]{14}", + "minLength": 32, + "maxLength": 32 + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful listing", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/item" + } + } + } + } + }, + "post": { + "tags": [ "merchant" ], + "operationId": "addItems", + "summary": "Add items", + "description": "Add one or more new items.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/item" + } + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "201": { + "description": "Successfully added the new item", + "schema": { + "type": "object", + "description": "An array containing the ids of the new items", + "properties": { + "ItemID": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/uuid" + } + } + } + } + } + } + } + }, + "/items/{objectId}": { + "x-swagger-router-controller": "api_items_controller", + "get": { + "tags": [ "merchant" ], + "operationId": "getItem", + "summary": "Item details", + "description": "This command returns the specified item", + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful", + "schema": { + "$ref": "api_definitions.json#/definitions/item" + } + } + } + }, + "post": { + "tags": [ "merchant" ], + "operationId": "updateItem", + "summary": "Update an item", + "description": "Creates a new version of the item with the associated modifications, and makes it the active version.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/item" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "201": { + "description": "Successfully added the new version of the item", + "schema": { + "type": "object", + "description": "The id of the new version of the item", + "properties": { + "ItemID": { + "$ref": "api_definitions.json#/definitions/uuid" + } + } + } + } + } + }, + "delete": { + "tags": [ "merchant" ], + "operationId": "deleteItem", + "summary": "Delete Item", + "description": "Deletes an item", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully deleted" + } + } + } + }, + "/devices": { + "x-swagger-router-controller": "api_devices_controller", + "get": { + "tags": [ "devices" ], + "operationId": "getDevices", + "summary": "List devices", + "description": "This command returns a list of devices for the current client", + "parameters": [ + { "$ref": "#/parameters/limitParam" }, + { "$ref": "#/parameters/skipParam" }, + { "$ref": "#/parameters/minDateParam" }, + { "$ref": "#/parameters/maxDateParam" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful listing", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/device" + } + } + } + } + }, + "post": { + "tags": [ "devices" ], + "operationId": "addDevice", + "summary": "Adds a device to a registered account.", + "description": "This command adds a device to a registered account", + "security": [], + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/AddDeviceBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Existing device found.\n \n Possible description/code: \n \n * Device re-registered. (10039) \n * Waiting for SMS code. (10042) \n * Device re-registered - please reset PIN. (10068)", + "schema": { + "allOf":[ + {"type": "object", + "properties": { + "DeviceID": { + "description": "Unique identifier for the device (created by the server)", + "$ref": "api_definitions.json#/definitions/uuid" + }, + "DeviceToken": { + "description": "A token that is unique to this device", + "$ref": "api_definitions.json#/definitions/token" + } + } + }, + {"$ref": "api_definitions.json#/definitions/SuccessInfo"} + ] + } + }, + "201": { + "description": "New device added. \n \n Possible description/code: \n \n * AddDevice successful. (10048) \n * Changing hardware ID. (10040)", + "schema": { + "allOf":[ + {"type": "object", + "properties": { + "DeviceID": { + "description": "Unique identifier for the device (created by the server)", + "$ref": "api_definitions.json#/definitions/uuid" + }, + "DeviceToken": { + "description": "A token that is unique to this device", + "$ref": "api_definitions.json#/definitions/token" + } + } + }, + {"$ref": "api_definitions.json#/definitions/SuccessInfo"} + ] + } + }, + "401": { + "description": "Invalid details. \n \n Possible causes: \n \n * Wrong password. (code: 411) \n * No client registration found. (code: 333)", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "403": { + "description": "Possible causes: \n \n * This phone number is registered to somebody else. (code: 338) \n * Maximum number of devices reached. (code: 359) \n * The device has been put on hold by Comcarde. (code: 341) \n * The device has been suspended by the user. (code 342) \n * Client barred. (code: 117) \n * Account Locked. (code: 406)", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "409": { + "description": "Failed to update the Device", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/devices/reportlost": { + "x-swagger-router-controller": "api_devices_controller", + "post": { + "tags": [ "devices" ], + "operationId": "reportLost", + "summary": "Reports a device as lost", + "description": "Reports the device as lost and suspends it, so it can't be used. This requires at least a partial login that is waiting for 2-factor authorisation.", + "security": [ { "awaiting_2fa_bridge_session": [ ] } ], + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/ReportLostBody" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Lost device has been suspended" + } + } + } + }, + "/devices/{objectId}": { + "x-swagger-router-controller": "api_devices_controller", + "get": { + "tags": [ "devices" ], + "operationId": "getDevice", + "summary": "Device details", + "description": "This command returns details on the specified device", + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful", + "schema": { + "$ref": "api_definitions.json#/definitions/device" + } + } + } + }, + "post": { + "tags": [ "devices" ], + "operationId": "updateDevice", + "summary": "Update device", + "description": "Updates editable parameters of a device. Larger changes like changing phone number, device etc. must re-register as a new device.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "api_definitions.json#/definitions/device" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful update" + } + } + }, + "delete": { + "tags": [ "devices" ], + "operationId": "deleteDevice", + "summary": "Delete device", + "description": "Deletes a device. The device will no longer be able to interact with server (no payments, transaction history, etc.). To use the device again it will need to be re-registered.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully deleted" + } + } + } + }, + "/devices/{objectId}/login": { + "x-swagger-router-controller": "api_devices_login_controller", + "post":{ + "tags": ["login"], + "summary": "Logs in to a device", + "description": "Allows a user to login via a device to get a session key that can be used for further requests.", + "operationId": "deviceLogin", + "security": [{ + "device_hmac_nosession": [] + }], + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "body", + "in":"body", + "description": "Body", + "required": true, + "schema":{ + "type": "object", + "properties": { + "ClientName": { + "$ref": "api_definitions.json#/definitions/email" + }, + "DeviceAuthorisation": { + "description": "The Pin for this device", + "$ref": "api_definitions.json#/definitions/deviceAuthorisation" + }, + "DeviceHardware": { + "$ref": "api_definitions.json#/definitions/DeviceHardware" + }, + "DeviceSoftware": { + "$ref": "api_definitions.json#/definitions/DeviceSoftware" + }, + "Location": { + "description": "Location of the device", + "$ref": "api_definitions.json#/definitions/geojson-point" + } + }, + "required": [ + "ClientName", + "DeviceAuthorisation", + "DeviceHardware", + "DeviceSoftware" + ] + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Login Successful", + "schema": { + "$ref": "api_definitions.json#/definitions/successfulDeviceLoginResponse" + } + }, + "202": { + "description": "Credentials accepted, but HMAC rotation must be confirmed before further requests are made", + "schema": { + "allOf": [ + { + "$ref": "api_definitions.json#/definitions/successfulDeviceLoginResponse" + }, + { + "$ref": "api_definitions.json#/definitions/pendingHmacResponse" + } + ] + } + }, + "401": { + "description": "Device not found, doesn't belong to ClientName, or the DeviceAuthorisation doesn't match.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "403": { + "description": "Client or Device not in the correct state. The DeviceAuthorisation may not be configured, or the device or client may be suspended or barred.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "409": { + "description": "A simultaneous conflicting change has prevented this operation from completing.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/devices/{objectId}/verification":{ + "x-swagger-router-controller": "api_devices_controller", + "post":{ + "tags": ["devices"], + "summary": "Verify the Phone Number", + "description": "Allow a user to verify their phone number", + "operationId": "verifyPhoneNumber", + "security": [], + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "body", + "in":"body", + "description": "Verify Phone Number Body", + "required": true, + "schema":{ + "type": "object", + "properties": { + "DeviceToken": { + "description": "A token that is unique to this device", + "$ref": "api_definitions.json#/definitions/token" + }, + "DeviceNumber": { + "$ref": "api_definitions.json#/definitions/phoneNumber" + }, + "RegistrationToken": { + "description": "A 6 digit code sent to the phone via SMS, which is used to verify the phone number.", + "allOf": [ + { "$ref": "api_definitions.json#/definitions/numeric" }, + { + "minLength": 6, + "maxLength": 6 + } + ], + "example": "123456" + } + }, + "required": [ + "DeviceToken", + "DeviceNumber", + "RegistrationToken" + ] + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully verified phone number (re-registration)." + }, + "201": { + "description": "Successfully verified phone number." + }, + "401": { + "description": "Possible causes: \n \n * Invalid Device ID \n * Invalid device number \n * Invalid device token \n * Invalid registration Token.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "403": { + "description": "Possible causes: \n \n * Device not in the correct state. \n * The Device may be suspended or barred. \n * Too many registration token attempts\n * Registration token has expired", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "409": { + "description": "Failed to update the Device", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/devices/{objectId}/pin":{ + "x-swagger-router-controller": "api_devices_controller", + "post":{ + "tags": ["devices"], + "summary": "Set your Pin for this device", + "description": "Allow a user to set their pin for their device.", + "operationId": "setPin", + "security": [], + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "body", + "in":"body", + "description": "Set Pin Body", + "required": true, + "schema":{ + "type": "object", + "properties": { + "DeviceToken": { + "description": "A token that is unique to this device", + "$ref": "api_definitions.json#/definitions/token" + }, + "ClientName": { + "$ref": "api_definitions.json#/definitions/email" + }, + "Location": { + "description": "Location of the device", + "$ref": "api_definitions.json#/definitions/geojson-point" + }, + "DeviceAuthorisation": { + "description": "The Pin for this device", + "$ref": "api_definitions.json#/definitions/deviceAuthorisation" + } + }, + "required": [ + "DeviceToken", + "ClientName", + "DeviceAuthorisation" + ] + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "201": { + "description": "Pin successfully set." + }, + "401": { + "description": "Possible causes: \n \n * Device not found \n * Device doesn't belong to ClientName, \n * The DeviceToken doesn't match.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "403": { + "description": "Possible causes: \n \n * Client or Device not in the correct state. \n * The Device or Client may be suspended or barred.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "409": { + "description": "Failed to update the Device", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/devices/{objectId}/suspend": { + "x-swagger-router-controller": "api_devices_controller", + "post": { + "tags": [ "devices" ], + "operationId": "suspendDevice", + "summary": "Suspend device", + "description": "Client requested suspension of the phone. Will prevent transactions being made on this phone until resumed. Can be useful if the phone is thought to be lost, etc.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully suspended operation of the app on the device" + } + } + } + }, + "/devices/{objectId}/resume": { + "x-swagger-router-controller": "api_devices_controller", + "post": { + "tags": [ "devices" ], + "operationId": "resumeDevice", + "summary": "Resume device", + "description": "Reverses the client requested suspension of the phone. The phone will now be able to make transactions again.", + "security": [ { "elevated_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully resumed operation of the app on the device" + } + } + } + }, + "/devices/{objectId}/bar": { + "x-swagger-router-controller": "api_devices_controller", + "post": { + "tags": [ "devices" ], + "operationId": "barDevice", + "summary": "Bar device", + "description": "Bars the device from use (suspected fraud, etc.). This is administrator driven and cannot be overridden by the client.", + "security": [ { "administrator_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully barred the device" + } + } + } + }, + "/devices/{objectId}/unbar": { + "x-swagger-router-controller": "api_devices_controller", + "post": { + "tags": [ "devices" ], + "operationId": "unbarDevice", + "summary": "Restores device (after barring).", + "description": "Restores the device from use after it was barred. This is administrator driven and cannot be done by the client.", + "security": [ { "administrator_bridge_session": [ ] } ], + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully unbarred the device" + } + } + } + }, + "/invoices": { + "x-swagger-router-controller": "api_invoices_controller", + "get": { + "tags": [ "merchant" ], + "operationId": "getInvoices", + "summary": "List invoices", + "description": "This command returns a list of outstanding invoices for the current merchant.", + "x-feature-flag": "invoices", + "parameters": [ + { "$ref": "#/parameters/limitParam" }, + { "$ref": "#/parameters/skipParam" }, + { "$ref": "#/parameters/minDateParam" }, + { "$ref": "#/parameters/maxDateParam" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful listing", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/pendingInvoice" + } + } + } + } + }, + "post": { + "tags": [ "merchant" ], + "operationId": "addInvoice", + "summary": "Add a new pending invoice", + "description": "Adds a new pending invoice.", + "security": [ { "elevated_bridge_session": [ ] } ], + "x-feature-flag": "invoices", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/addUpdateInvoice" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "201": { + "description": "Successfully added the new invoice(s)", + "schema": { + "type": "object", + "description": "An array containing the ids of the new invoices", + "properties": { + "InvoiceIDs": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/uuid" + } + } + } + } + }, + "403": { + "description": "The caller is not an active merchant and can't add invoices", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "409": { + "description": "The specified customer or account id doesn't exist in the system.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/invoices/{objectId}": { + "x-swagger-router-controller": "api_invoices_controller", + "get": { + "tags": [ "merchant" ], + "operationId": "getInvoice", + "summary": "Invoice details", + "description": "This command returns the specified invoice", + "x-feature-flag": "invoices", + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successful", + "schema": { + "$ref": "api_definitions.json#/definitions/pendingInvoiceDetail" + } + } + } + }, + "post": { + "tags": [ "merchant" ], + "operationId": "updateInvoice", + "summary": "Update and/or resubmit a rejected invoice", + "description": "Updates and/or resubmits a rejected invoice with new details. If 'resubmit' is set true, this will re-submit a rejected invoice (after any updates).", + "security": [ { "elevated_bridge_session": [ ] } ], + "x-feature-flag": "invoices", + "parameters": [ + { "$ref": "#/parameters/objectId" }, + { + "name": "resubmit", + "in": "query", + "type": "boolean" + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "api_definitions.json#/definitions/addUpdateInvoice" + } + } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully updated the invoice" + }, + "403": { + "description": "The caller is not an active merchant and can't add invoices", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + }, + "409": { + "description": "The specified customer or account id doesn't exist in the system.", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + }, + "delete": { + "tags": [ "merchant" ], + "operationId": "cancelInvoice", + "summary": "Cancel an invoice", + "description": "Cancels an invoice that hasn't been paid yet.", + "security": [ { "elevated_bridge_session": [ ] } ], + "x-feature-flag": "invoices", + "parameters": [ + { "$ref": "#/parameters/objectId" } + ], + "responses": { + "default": { "$ref": "api_responses.json#/responses/GeneralError" }, + "200": { + "description": "Successfully cancelled" + }, + "404": { + "description": "The invoice is not found, or not in Pending or Rejected state", + "schema": { + "$ref": "api_definitions.json#/definitions/ErrorInfo" + } + } + } + } + }, + "/csp-report": { + "x-swagger-router-controller": "api_csp_controller", + "post": { + "tags": [ + "utils" + ], + "consumes": [ "application/json", "application/csp-report" ], + "operationId": "cspReport", + "summary": "Receives CSP violation reports", + "description": "Receives CSP violation reports", + "security": [], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "Report", + "required": true, + "schema": { + "type": "object", + "properties": { + "csp-report": { + "$ref": "api_definitions.json#/definitions/CspReport" + } + } + } + } + ], + "responses": { + "204": { + "description": "Report received." + } + } + } + }, + "/utils/postcodeLookup/{postcode}": { + "x-swagger-router-controller": "api_postcodes_controller", + "get": { + "tags": [ + "utils" + ], + "operationId": "postcodeLookup", + "summary": "Postcode to addresses lookup", + "description": "Returns a list of addresses based on the provided postcode", + "parameters": [ + { + "name": "postcode", + "in": "path", + "description": "PostCode", + "required": true, + "type": "string" + } + ], + "responses": { + "default": { + "$ref": "api_responses.json#/responses/GeneralError" + }, + "200": { + "description": "Lookup successful.", + "schema": { + "type": "array", + "items": { + "$ref": "api_definitions.json#/definitions/address" + } + } + } + } + } + } + }, + "securityDefinitions": { + "bridge_session": { + "type": "apiKey", + "name": "X-XSRF-TOKEN", + "in": "header", + "description": "An active session with the Bridge server is required. This represents the basic level that all users initially get after successful log in. See <> for more details.", + "x-session-cookie": "X-BRIDGE-SESSION" + }, + "elevated_bridge_session": { + "type": "apiKey", + "name": "X-XSRF-TOKEN", + "in": "header", + "description": "As per the bridge_session, except this session is elevated to a higher security level using <>. This is required for certain secure operations (adding/removing accounts, etc.). Paths requiring only the standard session level can also be used with an elevated session level.", + "x-session-cookie": "X-BRIDGE-SESSION" + }, + "administrator_bridge_session": { + "type": "apiKey", + "name": "X-XSRF-TOKEN", + "in": "header", + "description": "As per the bridge_session, except for administrative users. Administrative level users can also use paths that require standard or elevated sessions.", + "x-session-cookie": "X-BRIDGE-SESSION" + }, + "reset_password_bridge_session": { + "type": "apiKey", + "name": "X-XSRF-TOKEN", + "in": "header", + "description": "A session to manage the password reset flow. The full password reset must be completed within this session. Sessions at this level *MAY NOT* use any paths that require any other session type.", + "x-session-cookie": "X-BRIDGE-SESSION" + }, + "awaiting_2fa_bridge_session": { + "type": "apiKey", + "name": "X-XSRF-TOKEN", + "in": "header", + "description": "A session for when email and password have been verified, but still waiting for 2FA to complete. Sessions at this level *MAY NOT* use any paths that require any other session type.", + "x-session-cookie": "X-BRIDGE-SESSION" + }, + "awaiting_accept_eula_bridge_session": { + "type": "apiKey", + "name": "X-XSRF-TOKEN", + "in": "header", + "description": "A session for when email and password have been verified, but still waiting for an updated EULA to be accepted. Sessions at this level *MAY NOT* use any paths that require any other session type.", + "x-session-cookie": "X-BRIDGE-SESSION" + }, + "recovery_session": { + "type": "apiKey", + "name": "X-XSRF-TOKEN", + "in": "header", + "description": "A session for handling account recovery.", + "x-session-cookie": "X-BRIDGE-SESSION" + }, + "device_session": { + "type": "apiKey", + "description": "Session from a mobile device. Sent as :. This also requires a valid HMAC to be passed in the `x-bridge-hmac` header, with timestamp in `x-bridge-timestamp`. See HMAC Implementation docs for more information.", + "name": "x-bridge-device-session", + "in": "header" + }, + "device_hmac_nosession": { + "type": "apiKey", + "description": "A variant of the session hmac for cases where there is no session yet. This also requires a client timestamp in `x-bridge-timestamp`. See HMAC Implementation docs for more information.", + "name": "x-bridge-hmac", + "in": "header" + } + }, + "parameters": { + "objectId": { + "name": "objectId", + "in": "path", + "required": true, + "type": "string", + "pattern": "^([A-Za-z0-9]{24})$", + "minLength": 24, + "maxLength": 24 + }, + "imageRef": { + "name": "imageRef", + "in": "path", + "required": true, + "type": "string", + "pattern": "^([a-f0-9]{24}|(defaultSelfie)|(defaultCompanyLogo0))$", + "minLength": 13, + "maxLength": 24 + }, + "addressID": { + "name": "addressID", + "in": "path", + "required": true, + "type": "string", + "pattern": "^([A-Za-z0-9]{24})$", + "minLength": 24, + "maxLength": 24 + }, + "skipParam": { + "name": "skip", + "in": "query", + "description": "number of items to skip", + "required": false, + "type": "integer", + "format": "int32", + "default": 0, + "minimum": 0 + }, + "limitParam": { + "name": "limit", + "in": "query", + "description": "max records to return", + "required": false, + "type": "integer", + "format": "int32", + "default": 30, + "minimum": 1, + "maximum": 30 + }, + "minDateParam": { + "name": "minDate", + "in": "query", + "description": "Records returned should have been dated after this ISO 8601 date-time", + "required": false, + "type": "string", + "format": "date-time" + }, + "maxDateParam": { + "name": "maxDate", + "in": "query", + "description": "Records returned should have been dated before this ISO 8601 date-time. Defaults to `now` if not set.", + "required": false, + "type": "string", + "format": "date-time" + } + } +} \ No newline at end of file diff --git a/node_server/swagger_api/api_utils.js b/node_server/swagger_api/api_utils.js new file mode 100644 index 0000000..8e01cbf --- /dev/null +++ b/node_server/swagger_api/api_utils.js @@ -0,0 +1,263 @@ +// +// This file contains utilities related to the web-focused API +// +'use strict'; + +const Q = require('q'); +const _ = require('lodash'); +const crypto = require('crypto'); + +const config = require(global.configFile); +const debug = require('debug')('webconsole-api:api_utils'); +const apiSecurity = require('./api_security.js'); + +const utils = require(global.pathPrefix + 'utils.js'); +const hashUtil = require(global.pathPrefix + '../utils/hashing.js'); + +const ERRORS = { + SESSION_REGEN_FAILED: 'Session Regen: Failed', + SESSION_DESTROY_FAILED: 'Session Destroy: Failed' +}; + +module.exports = { + initSession, + initRecoverySession, + + encodePassword, + + addPortAndAddress, + + ERRORS +}; + +/** + * Initialises the session for the specified client + * + * @param {Object} req - the express request object (for session and swagger) + * @param {string} email - the email for the client + * @param {Object} data - the data to store in the session + * + * @returns {Promise} - a promise for the completion of this function + */ +function initSessionBase(req, email, data) { + debug(' - initialising session for:', email); + const defer = Q.defer(); + + if (data.isDeviceSession) { + // + // Device sessions are simpler, and initialised every command. Therefore + // we don't want the session middleware to be saving them to persist them. + // Therefore we prevent saving by clearing req.sessionID + // + req.sessionID = null; + + // + // Still store the session data in the standard place if we were persisting + // sessions. + // + req.session.data = data; + + // + // And resolve the promise + // + defer.resolve({}); + } else { + // + // Webconsole sessions are full sessions + // Reset the session id (for security) + // + const dRegen = Q.defer(); + req.session.regenerate((err) => { + if (err) { + debug('- failed to regenerate session: ', err); + dRegen.reject(ERRORS.SESSION_REGEN_FAILED); + } else { + // + // Save the client info into the new session for future use + // + req.session.data = data; + dRegen.resolve(req.session); + } + }); + + // + // Set the XSRF token as a hash of the session token, salted with + // the users email address, then send it back in the body of the + // response (as JS can't read a token from a cross site request). + // + dRegen.promise + .then((session) => { + const xsrfTokenGen = apiSecurity.generateXsrfToken(session.id, email); + return xsrfTokenGen.on('readable', () => { + const response = {}; + const securityDef = req.swagger.swaggerObject.securityDefinitions; + const tokenName = securityDef.bridge_session.name; + + response[tokenName] = xsrfTokenGen.read(); + + defer.resolve(response); + }); + }) + .catch((error) => { + defer.reject(error); + }); + } + + return defer.promise; +} + +/** + * Initialises the standard session for the specified client + * + * @param {Object} req - the express request object (for session and swagger) + * @param {Object} client - the client object to build the session for + * @param {Object?} device - the device object (for device logins only) + * + * @returns {Promise} - a promise for the completion of this function + */ +function initSession(req, client, device) { + const email = client.ClientName; + + // + // Client is a merchant if any of the items in the Merchant array + // have MerchantStatus === 1 + // Client is VAT registered if they are both a merchant and have + // a VAT number specified. + // + const isMerchant = _.some(client.Merchant, { + MerchantStatus: 1 + }); + const isVATRegistered = _.some(client.Merchant, (item) => { + return item.MerchantStatus && item.VATNo; + }); + const data = { + client: client._id, + clientID: client.ClientID, + email, + displayName: client.DisplayName, + isMerchant, + isVATRegistered, + FeatureFlags: client.FeatureFlags, // Case needs to match that expected by flags utils + clientObj: client, + deviceObj: device, + isDeviceSession: !_.isUndefined(device) + }; + + return initSessionBase(req, email, data) + .then((response) => { + // + // Update the response with the other values + // + response.emailConfirmNeeded = !utils.bitsAllSet( + client.ClientStatus, + utils.ClientEmailVerifiedMask + ); + response.isMerchant = data.isMerchant; + response.isVATRegistered = data.isVATRegistered; + response.displayName = data.displayName; + + if (config.EULAVersion !== client.EULAVersionAccepted) { + response.newEULA = config.EULAVersion; + } + + response.featureFlags = client.FeatureFlags; + + return Q.resolve(response); + }); +} + +/** + * Initialises a session for the recovery process. + * + * @param {Object} req - the express request object (for session and swagger) + * @param {Object} client - the client object to build the session for + * + * @returns {Promise} - a promise for the completion of this function + */ +function initRecoverySession(req, client) { + debug('Initialising recoverySession'); + + const email = client.ClientName; + const data = { + clientID: client.ClientID, + email, + level: apiSecurity.SESSION_TYPES.RECOVERY + }; + + return initSessionBase(req, email, data); +} + +/** + * Encodes the password to the hash that is stored in the database. This is + * a 2 step process for the web-api: + * 1: run a sha-256 hash on the password (to match what the apps do internally + * 2: use the hashUtils to generate the full hash of this sha-256 hash + * + * @param {string} password - the password to hash + * + * @returns {promise} - a promise that resolves the hashed value and salt + */ +function encodePassword(password) { + // + // Hash the password, to match what the mobile clients do internally. + // Note, the hashing is async, so we use a promises. + // + const deferred = Q.defer(); + const promise = deferred.promise; + + const hasher = crypto.createHash('sha256'); + hasher.setEncoding('hex'); + hasher.end(password, 'utf8'); + + hasher.on('readable', () => { + const passwordHash = hasher.read(); + deferred.resolve(passwordHash); + }); + + // + // Next we need to pass the password to the hashing code to get it in the + // latest format + // + return promise.then((hashedPassword) => { + return hashUtil.generateHash(Number(config.passwordCryptoVersion), hashedPassword); + }); +} + +// +// Dynamically adds the remoteAddress and protocolPort to the req +// +// @param {Object} req - request object, with lots of important information +// @param {Object} def - definition that we are currently being called for +// +// @param {Object} connectionData - connection data that needs to be logged +// +function addPortAndAddress(req) { + /** + * Different firewall headers depending on the source of the data. + * To get in to this code the services have been called from a trusted proxy. + * Technically the protocolPort should always be 'HTTPS:443' if the code has + * reached here, but it is taken from the headers if available for verification. + */ + let remoteAddress; + let protocolPort; + switch (global.CURRENT_DEPLOYMENT_ENV) { + case 'Azure': + remoteAddress = req.ip.split(':')[0]; + protocolPort = req.protocol + ':' + req.headers['x-forwarded-port']; + break; + case 'Bluemix': + remoteAddress = req.headers.$wsra; + protocolPort = req.headers.$wssc + ':' + req.headers.$wssp; + break; + case 'Flexiion': + default: + remoteAddress = req.ip; + protocolPort = req.protocol + ':443'; + } + const connectionData = {}; + connectionData.remoteAddress = remoteAddress; + connectionData.protocolPort = protocolPort; + + return connectionData; +} + diff --git a/node_server/swagger_api/controllers/api_accounts_controller.js b/node_server/swagger_api/controllers/api_accounts_controller.js new file mode 100644 index 0000000..c11d9b1 --- /dev/null +++ b/node_server/swagger_api/controllers/api_accounts_controller.js @@ -0,0 +1,893 @@ +/** + * Controller to manage the accounts functions + */ +'use strict'; + +var _ = require('lodash'); +var Q = require('q'); +var httpStatus = require('http-status-codes'); +var mongodb = require('mongodb'); +var debug = require('debug')('webconsole-api:controllers:accounts'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var log = require(global.pathPrefix + 'log.js'); +var swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); +var anon = require(global.pathPrefix + '../utils/anon.js'); +var referenceUtils = require(global.pathPrefix + '../utils/references.js'); +var acquirerUtils = require(global.pathPrefix + '../utils/acquirers/acquirer.js'); +var responsesUtils = require(global.pathPrefix + '../utils/responses.js'); +const deleteAccountImpl = require(global.pathPrefix + '../impl/delete_account.js'); + +module.exports = { + getAccounts: getAccounts, + getAccount: getAccount, + + updateAccount: updateAccount, + deleteAccount: deleteAccount, + + addAccountCredorax: addAccountCredorax, + addAccountWorldpay: addAccountWorldpay, + addAccountDemo: addAccountDemo +}; + +/** + * Get the account history + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getAccounts(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var isMerchant = req.session.data.isMerchant; + var limit = req.swagger.params.limit.value; + var skip = req.swagger.params.skip.value; + var minDate = req.swagger.params.minDate.value; + var maxDate = req.swagger.params.maxDate.value; + var includeDeleted = req.swagger.params.includeDeleted.value; + + var query = { + ClientID: clientID + }; + + // + // Exclude deleted items unless we have been asked to keep them + // + if (!includeDeleted) { + // Ignore items with the AccountDelete or API created bits set + // jshint -W016 + query.AccountStatus = { + $bitsAllClear: utils.AccountDeleted | utils.AccountApiCreated + }; + // jshint +W016 + } + + // + // Add date limits if included + // + if (minDate || maxDate) { + query.LastUpdate = {}; + if (minDate) { + query.LastUpdate.$gte = minDate; + } + if (maxDate) { + query.LastUpdate.$lte = maxDate; + } + } + + // + // If the user is not a merchant then prevent listing any merchant accounts + // + if (!isMerchant) { + query.AccountType = {$ne: 'Credit/Debit Receiving Account'}; + } + + // + // Define the projection based on the Swagger definition + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true // include _id so we know how to select an individual device. + ); + + // + // Make the query. Note limit & skip have defaults defined in the + // swagger definition, so will always exist even if not requested + // + mainDB.collectionAccount.find(query, projection) + .skip(skip) + .limit(limit) + .sort({LastUpdate: -1}) // Hard-coded reverse sort by time + .toArray(function(err, items) { + if (err) { + debug('- failed to getAccounts', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 121, + info: 'Database offline' + }); + } else { + // + // Anonymise all the accounts before sending them out + // + _.forEach( + items, + function(value, index, collection) { + // + // Anonymise all the account before sending them out + // + anon.anonymiseAccount(value); + // + // Rename _id to AccountId + // + value.AccountID = value._id; + delete value._id; + }); + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, items); + + res.status(httpStatus.OK).json(items); + } + }); +} + +/** + * Gets the account details for a specific account. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getAccount(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var isMerchant = req.session.data.isMerchant; + var accountId = req.swagger.params.objectId.value; + + // + // Build the query. The limits are: + // - Must match the id of the item we are looking for + // - Current user must be the account owner (to protect against Insecure + // Direct Object References). + // + var query = { + _id: mongodb.ObjectID(accountId), + ClientID: clientID + }; + + // + // If the user is not a merchant then prevent listing any merchant accounts + // + if (!isMerchant) { + query.AccountType = {$ne: 'Credit/Debit Receiving Account'}; + } + + // + // Define the fields based on the Swagger definition. + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true + ); + + // + // Build the options to encapsulate the projection + // + var options = { + fields: projection, + comment: 'WebConsole:getAccount' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionAccount, query, options, false, + function(err, item) { + if (err) { + debug('- failed to getAccount', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 197, + info: 'Database offline' + }); + } else if (item === null) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 192, + info: 'Not found' + }); + } else { + // + // Anonymise all the account before sending them out + // + anon.anonymiseAccount(item); + // + // Rename _id to AccountId + // + item.AccountID = item._id; + delete item._id; + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, item); + + res.status(httpStatus.OK).json(item); + } + }); +} + +/** + * Updates billing address or customer name for a specific account. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function updateAccount(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var accountId = req.swagger.params.objectId.value; + + // + // Get the optional parameters for the update + // + var accountName = req.swagger.params.body.value.ClientAccountName; + var billingAddress = req.swagger.params.body.value.BillingAddress; + var lock = req.swagger.params.body.value.Lock; + + if (accountName === undefined && + billingAddress === undefined && + lock === undefined) { + // + // Nothing to update + // + res.status(httpStatus.BAD_REQUEST).json({ + code: 30004, + info: 'No update parameters included' + }); + return; + } + + // + // If we have been given a new billing address we need to check it actually + // belongs to the user. This requires a database lookup so we use a + // promise to wait for the result. + // + var validAddressPromise; + const FAILED_UPDATE = 'BRIDGE: FAILED UPDATE'; + const NULL_ADDRESS = 'BRIDGE: ADDR CANT BE NULL'; + + if (billingAddress) { + validAddressPromise = referenceUtils.isValidAddressRef( + clientID, + billingAddress, + 'WebConsole:updateAccount' + ); + } else { + // + // Haven't asked to update billing address, so nothing to check + // + validAddressPromise = Q.resolve(); + } + + // + // Also check that we don't have an account with the same name already + // + var validAccountPromise = Q.resolve(); + const SAME_NAME = 'Bridge: Account with the same name'; + if (accountName) { + var options = { + fields: { + _id: true + }, + comment: 'WebConsole:updateAccount' + }; + var uniqueNameQuery = { + ClientID: clientID, + ClientAccountName: accountName + }; + + validAccountPromise = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionAccount, + uniqueNameQuery, + options, + false + ).then(function(result) { + if (result !== null && result._id.toString() !== accountId) { + // Some other account has this name + return Q.reject({name: SAME_NAME}); + } + return Q.resolve(); + }); + } + + // + // Update the account, after checking the billing address is valid + // + var updatePromise = Q.all([validAddressPromise, validAccountPromise]).then(function() { + var query = { + _id: mongodb.ObjectID(accountId), // The account to update + ClientID: clientID, // Must be *my* account + AccountStatus: { + $bitsAllClear: utils.AccountDeleted // Must not be "deleted" + } + }; + + var updates = { + $set: { + LastUpdate: new Date() + }, + $inc: { + LastVersion: 1 + } + }; + if (accountName !== undefined) { + updates.$set.ClientAccountName = accountName; + } + if (billingAddress !== undefined) { + // + // Special case: can't set the billing address to NULL (but this + // isn't checked by validation because we could *return* a null + // billing address for legacy accunts. + // + if (billingAddress === null) { + return Q.reject({name: NULL_ADDRESS}); + } + updates.$set.BillingAddress = billingAddress; + } + + // + // Update the locked status of the account + // jshint -W016 + // + if (lock !== undefined) { + if (lock) { + updates.$bit = { + AccountStatus: {or: utils.AccountLocked} + }; + } else { + updates.$bit = { + AccountStatus: {and: ~utils.AccountLocked} + }; + } + } + // jshint +W016 + + var options = { + upsert: false, + multi: false + }; + + return Q.nfcall( + mainDB.updateObject, + mainDB.collectionAccount, + query, + updates, + options, + false + ).then(function(results) { + if (results.result.n === 0) { + return Q.reject({name: FAILED_UPDATE}); + } else { + return Q.resolve(); + } + }); + }); + + // + // Run all the promises and check they pass + // + Q.all([validAddressPromise, validAccountPromise, updatePromise]) + .then(function() { + // All good + res.status(200).json(); + }) + .catch(function(error) { + debug('-- error updating account: ', error); + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case referenceUtils.ERRORS.INVALID_ADDRESS: + // Billing address is not valid + res.status(httpStatus.NOT_FOUND).json({ + code: 393, + info: 'Billing address not found' + }); + break; + + case NULL_ADDRESS: + // Billing address is not valid + res.status(httpStatus.BAD_REQUEST).json({ + code: 393, + info: 'Billing address can\'t be null' + }); + break; + + case FAILED_UPDATE: + // Couldn't update - probably not *my* account + res.status(httpStatus.NOT_FOUND).json({ + code: 395, + info: 'Account not found' + }); + break; + + case SAME_NAME: + // Can't have an account with the same name + res.status(httpStatus.CONFLICT).json({ + code: 30107, + info: 'Account with the same name already exists' + }); + break; + + case 'MongoError': + // Mongo Error + res.status(httpStatus.BAD_GATEWAY).json({ + code: 392, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }) + .done(); // Catch all +} + +/** + * Base function to adds a new merchant account. All merchant accounts are added + * in the same basic way, but may have different requirements for the format + * of parameters (particualarly MerchantID and ClientKey). Any validation + * of format, or liveness of account, should be done before calling this function, + * which assumes everything is correct. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Object} extData - additional info to set in the account + * @param {String} extData.VendorAccountName - name of account at aquirer + * @param {String} extData.AcquirerName - Name of acquirer (e.g. "Demo") + * @param {String} extData.VendorID - ID acquirer in our system (e.g. "Bridge") + * @param {String} extData.IconLocation - Name of the icon for this acquirer + * + * @return {Promise} - A promise for the result of adding the account + */ +function addAccountBase(req, res, extData) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var isMerchant = req.session.data.isMerchant; + + // + // Precondition 0: Must be able to encrypt the account ID and cipher + // + let encryptP = Q.resolve(); + let encMerchantID = ''; + let encCipher = ''; + const ENCRYPTION_FAILED = 'BRIDGE: Encryption failed'; + if (extData.VendorID !== 'Bridge') { + encMerchantID = utils.encryptDataV1(req.swagger.params.body.value.AcquirerMerchantID); + encCipher = utils.encryptDataV1(req.swagger.params.body.value.AcquirerCipher); + + if (!_.isString(encMerchantID) || !_.isString(encCipher)) { + encryptP = Q.reject({name: ENCRYPTION_FAILED}); + } + } + + // + // Merge the default blank account plus the parameters from the request + // + var newAccount = _.assign( + mainDB.blankAccount(), + { + // Base items + AccountType: 'Credit/Debit Receiving Account', + ReceivingAccount: 1, + PaymentsAccount: 0, + AcquirerName: extData.AcquirerName, + VendorID: extData.VendorID, + IconLocation: extData.IconLocation, + UserImage: 'CompanyLogo0', + AccountStatus: utils.AccountLocked, // Prevents the account being deleted + VendorAccountName: extData.VendorAccountName, + + // Items from the request + NameOnAccount: req.swagger.params.body.value.NameOnAccount, + ClientAccountName: req.swagger.params.body.value.ClientAccountName, + BillingAddress: req.swagger.params.body.value.BillingAddress, + AcquirerMerchantID: encMerchantID, + AcquirerCipher: encCipher, + + // Calculated items + ClientID: clientID, + LastUpdate: new Date(), + LastVersion: 1 + }); + + // + // Precondition 1: Must be a merchant + // + const NOT_A_MERCHANT = 'BRIDGE: Client is not a registered merchant'; + const isAMerchantP = isMerchant ? Q.resolve() : Q.reject({name: NOT_A_MERCHANT}); + + // + // Precondition 2: Merchant must be configured (have CompanyName and + // CompanyAlias). + // + var merchantConfigQuery = { + ClientID: clientID, + 'Merchant.0.CompanyName': {$ne: ''}, + 'Merchant.0.CompanyAlias': {$ne: ''} + }; + var options = { + fields: {}, // Don't want any fields, just checking existence + comment: 'WebConsole:addAccountCredorax' + }; + const MERCHANT_NOT_CONFIGURED = 'Bridge: Merchant is not configured'; + var merchantConfiguredP = isAMerchantP.then(() => { + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + merchantConfigQuery, + options, + false + ).then(function(result) { + if (!result) { + return Q.reject({name: MERCHANT_NOT_CONFIGURED}); + } + return Q.resolve(); + }); + }); + + // + // Precondition 3: Client must have a device registered before + // + var deviceQuery = { + ClientID: clientID, + DeviceStatus: { + $bitsAllSet: utils.DeviceFullyRegistered + } + }; + + const NO_DEVICE = 'Bridge: No devices on account'; + var hasDeviceP = merchantConfiguredP.then(function() { + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionDevice, + deviceQuery, + options, + false).then(function(result) { + if (!result) { + return Q.reject({name: NO_DEVICE}); + } + return Q.resolve(); + }); + }); + + // + // Precondition 4: Must not have an active account with the same name already + // + const SAME_NAME = 'Bridge: Account with the same name'; + var uniqueNameQuery = { + ClientID: clientID, + ClientAccountName: newAccount.ClientAccountName, + AccountStatus: { + $bitsAllClear: utils.AccountDeleted + } + }; + + var checkUniqueNameP = hasDeviceP.then(function() { + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionAccount, + uniqueNameQuery, + options, + false + ).then(function(result) { + if (result !== null) { + return Q.reject({name: SAME_NAME}); + } + return Q.resolve(); + }); + }); + + // + // Precondition 5: Address must exist and belong to me + // + var addressExistsP = checkUniqueNameP.then(function() { + return referenceUtils.isValidAddressRef( + clientID, + newAccount.BillingAddress, + 'WebConsole:addAccountBase' + ); + }); + + // + // Precondition 6: Account must be validated with the acquirer + // + var validateP = addressExistsP.then( + () => acquirerUtils.validateMerchantAccount(newAccount) + ); + + // + // Add the account details to the database + // + var addP = validateP.then(function() { + return Q.nfcall( + mainDB.addObject, + mainDB.collectionAccount, + newAccount, + undefined, + false + ); + }); + + // + // Run all the promises and return the result + // + return Q.all([encryptP, isAMerchantP, merchantConfiguredP, hasDeviceP, checkUniqueNameP, addressExistsP, validateP, addP]) + .then(function(results) { + var newAccountResult = results[7][0]; + res.status(201).json({ + id: newAccountResult._id + }); + return; + }) + .catch(function(error) { + debug('-- error adding account: ', error); + const responses = [ + [ + 'MongoError', + // Mongo Error + httpStatus.BAD_GATEWAY, 30104, 'Database Unavailable', + true + ], + [ + referenceUtils.ERRORS.INVALID_ADDRESS, + httpStatus.NOT_FOUND, 30102, 'Billing address not found', + true + ], + [ + ENCRYPTION_FAILED, + httpStatus.BAD_REQUEST, -1, 'Invalid AcquirerMerchantID or AcquirerCipher', + true + ], + [ + NOT_A_MERCHANT, + httpStatus.FORBIDDEN, 30101, 'Client is not a merchant', + true + ], + [ + MERCHANT_NOT_CONFIGURED, + httpStatus.PRECONDITION_FAILED, 30106, + 'Company details must be configured before adding a merchant account', + true + ], + + [ + SAME_NAME, + httpStatus.CONFLICT, 30103, 'An account with the same description already exists', + true + ], + + [ + NO_DEVICE, + httpStatus.PRECONDITION_FAILED, 30105, + 'Client must have a registered device before adding a merchant account', + true + ], + // + // Errors from the acquirer validation + // + [ + acquirerUtils.ERRORS.UNKNOWN_ACQUIRER, + httpStatus.BAD_REQUEST, 30109, 'Merchant acquirer unknown', + true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_DOWN, + httpStatus.BAD_GATEWAY, 30110, 'Cannot connect to acquirer', + true + ], + [ + acquirerUtils.ERRORS.INVALID_MERCHANT_ACCOUNT_DETAILS, + httpStatus.BAD_REQUEST, 30111, 'Receiving account information unreadable', + true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_UNKNOWN_ERROR, + httpStatus.BAD_GATEWAY, 30112, 'Unknown acquirer error', + true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_BAD_REQUEST, + httpStatus.INTERNAL_SERVER_ERROR, 30113, 'Bad request to acquirer', + true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_UNAUTHORIZED, + httpStatus.BAD_REQUEST, 30114, 'Merchant account details invalid.', + true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_MERCHANT_DISABLED, + httpStatus.BAD_REQUEST, 30115, 'Merchant account disabled. Re-enable before adding', + true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_INTERNAL_SERVER_ERROR, + httpStatus.BAD_GATEWAY, 30116, 'Internal server error at enquirer', + true + ] + ]; + + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }) + .done(); +} + +/** + * Adds a new Credorax merchant account. This can only be done for clients who + * have been enabled as merchants, and do not already have the max number of + * accounts + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function addAccountCredorax(req, res) { + // + // TODO: Validate account against Credorax + // + + // + // Call the base function to add it to the database + // + return addAccountBase(req, res, { + VendorAccountName: 'Merchant Card Account', // Not used by Credorax so hardcode + AcquirerName: 'Credorax', + VendorID: 'Credorax', + IconLocation: 'credorax-account.png' + }); +} + +/** + * Adds a new Worldpay merchant account. This can only be done for clients who + * have been enabled as merchants, and do not already have the max number of + * accounts + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function addAccountWorldpay(req, res) { + // + // TODO: Validate account against Worldpay + // + + // + // Call the base function to add it to the database + // + return addAccountBase(req, res, { + VendorAccountName: 'Merchant Card Account', // Not used by worldpay so hardcode + AcquirerName: 'Worldpay', + VendorID: 'Worldpay', + IconLocation: 'worldpay-account.png' + }); +} + +/** + * Adds a new Demo merchant account. This can only be done for clients who + * have been enabled as merchants, and do not already have the max number of + * accounts. + * This is an internal demo account that will never perform a real charge against + * a card. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function addAccountDemo(req, res) { + // + // Add (Demo) to the client account name, to help make it more obvious + // + req.swagger.params.body.value.ClientAccountName += ' (Demo)'; + + // + // Call the base function to add it to the database + // + return addAccountBase(req, res, { + VendorAccountName: 'Standard Merchant Account', // Not used, so hardcode + AcquirerName: 'Demo', + VendorID: 'Bridge', + IconLocation: 'BRIDGE_MERCHANT.png' + }); +} + +/** + * Deletes an account from the system by setting the "deleted" status. It also + * attempts to disable the token on the merchant aquirer system if appropriate. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + */ +function deleteAccount(req, res) { + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const accountId = req.swagger.params.objectId.value; + + // + // Call the implementation and return the properly formatted + // + return deleteAccountImpl.deleteAccount(clientID, accountId) + .then(function() { + return res.status(200).json(); + }) + .catch(function(error) { + debug('-- error deleting account: ', error); + const responses = [ + [ + deleteAccountImpl.ERRORS.RELATED_INVOICES, + httpStatus.CONFLICT, 30108, 'Account can\'t be deleted while related active invoices exist', true + ], + [ + deleteAccountImpl.ERRORS.NOT_FOUND, + // AccountID is not valid (or doesn't belong to *me*) + httpStatus.NOT_FOUND, 153, 'Account not found', true + ], + [ + deleteAccountImpl.ERRORS.FAILED_UPDATE, + // AccountID is not valid (or doesn't belong to *me*) + httpStatus.NOT_FOUND, 153, 'Account not found', true + ], + [ + deleteAccountImpl.ERRORS.LOCKED, + httpStatus.FORBIDDEN, 243, 'Account is locked and cant be deleted', true + ], + [ + acquirerUtils.ERRORS.UNKNOWN_ACQUIRER, + httpStatus.INTERNAL_SERVER_ERROR, 241, 'Unknown acquirer', true + ], + [ + acquirerUtils.ERRORS.ACQUIRER_DOWN, + httpStatus.BAD_GATEWAY, 244, 'Cannot connect to acquiring bank', true + ], + [ + 'MongoError', + // Mongo Error + httpStatus.BAD_GATEWAY, 30104, 'Database Unavailable' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + + return responseHandler.respond(res, error); + }) + .done(); +} diff --git a/node_server/swagger_api/controllers/api_addresses_controller.js b/node_server/swagger_api/controllers/api_addresses_controller.js new file mode 100644 index 0000000..2e47ced --- /dev/null +++ b/node_server/swagger_api/controllers/api_addresses_controller.js @@ -0,0 +1,548 @@ +/** + * Controller to manage the addresses functions + */ +'use strict'; + +var _ = require('lodash'); +var Q = require('q'); +var httpStatus = require('http-status-codes'); +var mongodb = require('mongodb'); +var debug = require('debug')('webconsole-api:controllers:addresses'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); +var anon = require(global.pathPrefix + '../utils/anon.js'); +var config = require(global.configFile); + +module.exports = { + getAddresses: getAddresses, + getAddress: getAddress, + addAddress: addAddress, + deleteAddress: deleteAddress +}; + +/** + * Get the address list + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getAddresses(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var limit = req.swagger.params.limit.value; + var skip = req.swagger.params.skip.value; + var minDate = req.swagger.params.minDate.value; + var maxDate = req.swagger.params.maxDate.value; + + var query = { + ClientID: clientID + }; + + // + // Add date limits if included + // + if (minDate || maxDate) { + query.LastUpdate = {}; + if (minDate) { + query.LastUpdate.$gte = minDate; + } + if (maxDate) { + query.LastUpdate.$lte = maxDate; + } + } + + // + // Define the projection based on the Swagger definition + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true // include _id so we know how to select an individual address. + ); + + // + // Make the query. Note limit & skip have defaults defined in the + // swagger definition, so will always exist even if not requested + // + mainDB.collectionAddresses.find(query, projection) + .skip(skip) + .limit(limit) + .sort({LastUpdate: -1}) // Hard-coded reverse sort by time + .toArray(function(err, items) { + if (err) { + debug('- failed to getAddresses', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 377, + info: 'Database offline' + }); + } else { + // + // Anonymise all the addresses before sending them out + // + _.forEach( + items, + function(value, index, collection) { + anon.anonymiseAddress(value); + // + // Rename _id to AddressID + // + value.AddressID = value._id; + delete value._id; + + // + // Rename PhoneNumber to PhoneNumberAnon. + // (we anonymise the response, but obviously not the set) + // + value.PhoneNumberAnon = value.PhoneNumber; + delete value.PhoneNumber; + }); + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, items); + + res.status(httpStatus.OK).json(items); + } + }); +} + +/** + * Gets the address details for a specific address. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getAddress(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var addressId = req.swagger.params.objectId.value; + + // + // Build the query. The limits are: + // - Must match the id of the item we are looking for + // - Current user must be the address owner (to protect against Insecure + // Direct Object References). + // + var query = { + _id: mongodb.ObjectID(addressId), + ClientID: clientID + }; + + // + // Define the fields based on the Swagger definition. + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true + ); + + // + // Build the options to encapsulate the projection + // + var options = { + fields: projection, + comment: 'WebConsole:getAddress' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionAddresses, query, options, false, + function(err, item) { + if (err) { + debug('- failed to getAddress', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 377, + info: 'Database offline' + }); + } else if (item === null) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 393, + info: 'Not found' + }); + } else { + // + // Anonymise all the address before sending them out + // + anon.anonymiseAddress(item); + + // + // Rename PhoneNumber to PhoneNumberAnon. + // (we anonymise the response, but obviously not the set) + // + item.PhoneNumberAnon = item.PhoneNumber; + delete item.PhoneNumber; + + // + // Rename _id to AddressId + // + item.AddressID = item._id; + delete item._id; + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, item); + + res.status(httpStatus.OK).json(item); + } + }); +} + +/** + * Adds a new address + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function addAddress(req, res) { + var clientID = req.session.data.clientID; + var validatedBody = req.swagger.params.body.value; + + // + // Step 1: Find all existing addresses + // + var findQuery = { + ClientID: clientID + }; + + var findPromise = Q.ninvoke(mainDB.collectionAddresses, 'find', findQuery) + .ninvoke('toArray'); + + // + // Step 2: Check existing addresses and confirm that we: + // 1) don't have too many already + // 2) don't already have one with this description + // + const MAX_ADDR = 'BRIDGE: MAX ADDRESSES'; + const DESC_IN_USE = 'BRIDGE: DESCRIPTION ALREADY IN USE'; + + var checkPromise = findPromise.then(function(results) { + if (results.length > config.maxAddresses) { + return Q.reject({name: MAX_ADDR}); + } + + for (var i = 0; i < results.length; ++i) { + if (results[i].AddressDescription === validatedBody.AddressDescription) { + return Q.reject({name: DESC_IN_USE}); + } + } + return true; + }); + + // + // Step 3: Add the new address + // + var timestamp = new Date(); + var newAddress = mainDB.blankAddress(); + var required = ['AddressDescription', 'Address1', 'Town', 'PostCode', 'Country']; + var optional = ['BuildingNameFlat', 'Address2', 'County', 'PhoneNumber']; + var idx = 0; + for (idx = 0; idx < required.length; ++idx) { + newAddress[required[idx]] = validatedBody[required[idx]]; + } + for (idx = 0; idx < optional.length; ++idx) { + var key = optional[idx]; + if (validatedBody.hasOwnProperty(key) && validatedBody[key] !== null) { + newAddress[key] = validatedBody[key]; + } else { + newAddress[key] = ''; + } + } + newAddress.ClientID = clientID; + newAddress.DateAdded = timestamp; + newAddress.LastUpdate = timestamp; + newAddress.LastVersion = 1; + + var addPromise = checkPromise.then(function() { + return Q.nfcall( + mainDB.addObject, + mainDB.collectionAddresses, + newAddress, + undefined, + false + ); + }); + + // + // Step 4. Run all the promises and wait for the result + // + Q.all([findPromise, checkPromise, addPromise]) + .then(function success(result) { + // + // Succeeded + // The _id is in result[2][0] because: + // Result is an array of results from the 3 promises in .all() + // Thus result[2] is the result of addPromise + // This is an addObject() which returns an array itself. But we + // are only adding one so we know it is result[2][0]. + // + res.status(201).json({ + AddressID: result[2][0]._id + }); + }) + .catch(function fail(error) { + debug('-- error adding address: ', error); + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case MAX_ADDR: + // + // User already has max addresses + // + res.status(httpStatus.CONFLICT).json({ + code: 380, + info: 'Max addresses reached' + }); + break; + + case DESC_IN_USE: + // + // Device is barred + // + res.status(httpStatus.CONFLICT).json({ + code: 381, + info: 'An address with the same description already exists' + }); + break; + + case 'MongoError': + // + // Mongo Error + // + res.status(httpStatus.BAD_GATEWAY).json({ + code: 365, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }) + .done(); // Catch all +} + +/** + * Deletes a Address such that it can no longer be used in the system. + * What it actually does is copies the document to the AddressArchive collection + * then deletes it from the Addresses collection. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function deleteAddress(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var addressId = req.swagger.params.objectId.value; + + // + // Find the Address we want to delete + // + var query = { + _id: mongodb.ObjectID(addressId), // The Address to update + ClientID: clientID // Must be *my* Address + }; + var options = { + comment: 'WebConsole: find for deleteAddress' + }; + + // + // Step 1: Find the Address. This includes checking ownership by the + // the user with the current session. + // + var findOriginalPromise = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionAddresses, + query, + options, + false + ); + + // + // Step 2: Find any accounts that are using it + // + var findInUseQuery = { + ClientID: clientID, + BillingAddress: addressId + }; + var findInUsePromise = Q.ninvoke( + mainDB.collectionAddresses, + 'find', + findInUseQuery) + .ninvoke('toArray'); + + // + // Step 3: Check that we didn't find any accounts using it + // We can only delete addresses that are not currently in use. + // + const IN_USE = 'BRIDGE: ADDRESS IN USE'; + var ensureNotInUsePromise = findInUsePromise.then(function(results) { + if (results && results.length > 0) { + return Q.reject({name: IN_USE}); + } + + return true; + }); + + // + // Step 4: Once we've found that we own it and it's not in use, we can + // go and back it up + // + const NOT_FOUND = 'BRIDGE: NOT FOUND'; + var oldId = null; + var addArchivePromise = + Q.all([findOriginalPromise, findInUsePromise, ensureNotInUsePromise]) + .then(function(results) { + var item = results[0]; // The find original result is the first one. + // + // DB query ran ok, but need to check if there were any results + // + if (!item) { + return Q.reject({name: NOT_FOUND}); + } else { + var insertObject = _.clone(item); + oldId = item._id; + insertObject.AddressID = item._id.toString(); // Backup the old index + delete insertObject._id; // Then delete it to get a new one + + // + // Set LastUpdate to the current date + // + insertObject.LastUpdate = new Date(); + + // + // And insert into the archive + // + return Q.nfcall( + mainDB.addObject, + mainDB.collectionAddressArchive, + insertObject, + undefined, + false + ); + } + }); + + // + // Step 5: Delete the original + // + const NOT_ARCHIVED = 'BRIDGE: NOT ARCHIVED'; + var deleteOriginalPromise = addArchivePromise.then(function(result) { + if (!_.isObject(result)) { + return Q.reject({name: NOT_ARCHIVED}); + } else { + debug('Deleting orginal: ', oldId); + var deleteQuery = { + _id: oldId + }; + return Q.nfcall( + mainDB.removeObject, + mainDB.collectionAddresses, + deleteQuery, + undefined, + false + ); + } + }); + + // + // Run them all in sequence and check the result + // + Q.all([ + findOriginalPromise, findInUsePromise, ensureNotInUsePromise, + addArchivePromise, deleteOriginalPromise + ]) + .then(function success() { + // + // Succeeded + // + res.status(200).json(); + }) + .catch(function fail(error) { + debug('-- error deleting Address: ', error); + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case NOT_FOUND: + // + // Address not found in the DB (or doesn't belong to + // this user) + // + res.status(httpStatus.NOT_FOUND).json({ + code: 388, + info: 'Address not found' + }); + break; + + case NOT_ARCHIVED: + // + // Item failed to archive + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: 389, + info: 'Failed to archive Address' + }); + break; + + case 'MongoError': + // + // Mongo Error + // + res.status(httpStatus.BAD_GATEWAY).json({ + code: 385, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }) + .done(); // Catch all +} diff --git a/node_server/swagger_api/controllers/api_csp_controller.js b/node_server/swagger_api/controllers/api_csp_controller.js new file mode 100644 index 0000000..e96e477 --- /dev/null +++ b/node_server/swagger_api/controllers/api_csp_controller.js @@ -0,0 +1,22 @@ +/** + * Controller to manage the Content Security Policy reporting functions + */ +'use strict'; + +var httpStatus = require('http-status-codes'); +var debug = require('debug')('webconsole-api:controllers:csp'); + +module.exports = { + cspReport: cspReport +}; + +/** + * Handles a CSP report + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function cspReport(req, res) { + debug('CSP REPORT: ', req.swagger.params.body.value); + res.status(httpStatus.NO_CONTENT).end(); +} diff --git a/node_server/swagger_api/controllers/api_devices_controller.js b/node_server/swagger_api/controllers/api_devices_controller.js new file mode 100644 index 0000000..634801b --- /dev/null +++ b/node_server/swagger_api/controllers/api_devices_controller.js @@ -0,0 +1,730 @@ +/* eslint-disable promise/always-return */ +/* eslint-disable promise/catch-or-return */ +/* eslint-disable no-negated-condition */ + +/** + * Controller to manage the devices functions + */ +'use strict'; + +const _ = require('lodash'); +const Q = require('q'); +const httpStatus = require('http-status-codes'); +const mongodb = require('mongodb'); +const debug = require('debug')('webconsole-api:controllers:devices'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); +const promiseUtils = require(global.pathPrefix + '../utils/promises.js'); +const anon = require(global.pathPrefix + '../utils/anon.js'); + +const addDevice = require('./api_devices_controllers/api_addDevice.js'); +const setPin = require('./api_devices_controllers/api_setPin.js'); + +module.exports = { + setPin: setPin.setPin, + addDevice: addDevice.addDevice, + getDevices, + getDevice, + updateDevice, + suspendDevice, + resumeDevice, + deleteDevice, + reportLost +}; + +/** + * Get the device history + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getDevices(req, res) { + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const limit = req.swagger.params.limit.value; + const skip = req.swagger.params.skip.value; + const minDate = req.swagger.params.minDate.value; + const maxDate = req.swagger.params.maxDate.value; + + const query = { + ClientID: clientID + }; + + // + // Add date limits if included + // + if (minDate || maxDate) { + query.LastUpdate = {}; + if (minDate) { + query.LastUpdate.$gte = minDate; + } + if (maxDate) { + query.LastUpdate.$lte = maxDate; + } + } + + // + // Define the projection based on the Swagger definition + // + const projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true // include _id + ); + + // + // Make the query. Not limit & skip have defaults defined in the + // swagger definition, so will always exist even if not requested + // + mainDB.collectionDevice.find(query, projection) + .skip(skip) + .limit(limit) + .sort({LastUpdate: -1}) // Hard-coded reverse sort by time + .toArray((err, items) => { + if (err) { + debug('- failed to getDevices', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 197, + info: 'Database offline' + }); + } else { + _.forEach( + items, + (value) => { + anon.anonymiseDevice(value); + + // + // Rename _id to DeviceId + // + value.DeviceID = value._id; + delete value._id; + }); + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, items); + + res.status(httpStatus.OK).json(items); + } + }); +} + +/** + * Gets the device details for a specific device. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getDevice(req, res) { + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const deviceId = req.swagger.params.objectId.value; + + // + // Build the query. The limits are: + // - Must match the id of the item we are looking for + // - Current user must be the owner (for security, to protect + // against Insecure Direct Object References). + // + const query = { + _id: mongodb.ObjectID(deviceId), + ClientID: clientID + }; + + // + // Define the projection based on the Swagger definition + // + const projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true // include _id to reflect back to the user. + ); + + // + // Build the options to encapsulate the projection + // + const options = { + fields: projection, + comment: 'WebConsole:getDevice' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionDevice, query, options, false, + (err, item) => { + if (err) { + debug('- failed to getDevice', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 197, + info: 'Database offline' + }); + } else if (item === null) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30001, + info: 'Not found' + }); + } else { + anon.anonymiseDevice(item); + + // + // Rename _id to DeviceId + // + item.DeviceID = item._id; + delete item._id; + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, item); + + res.status(httpStatus.OK).json(item); + } + }); +} + +/** + * Updates editable device details for a specific device. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function updateDevice(req, res) { + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const deviceId = req.swagger.params.objectId.value; + + // + // Get the optional parameters for the update + // + const deviceName = req.swagger.params.body.value.DeviceName; + let defaultAccount = req.swagger.params.body.value.DefaultAccount; + + if (deviceName === undefined && defaultAccount === undefined) { + // + // Nothing to update + // + res.status(httpStatus.BAD_REQUEST).json({ + code: 30004, + info: 'No update parameters included' + }); + return; + } + + // + // If we have been given a default account we need to check it actually + // belongs to the user. This requires a database lookup so we use a + // promise to wait for the result. + // + const defer = Q.defer(); + const promise = defer.promise; + if (defaultAccount) { + const accQuery = { + _id: mongodb.ObjectID(defaultAccount), // The id given + ClientID: clientID, // Must be *my* account + AccountStatus: { + // jshint -W016 + $bitsAllClear: utils.AccountDeleted | utils.AccountApiCreated + // jshint +W016 + } + }; + const fields = {}; // Don't want any fields, just checking existence + const options = { + fields, + comment: 'WebConsole:updateDevice' + }; + mainDB.findOneObject(mainDB.collectionAccount, accQuery, options, false, + (err, item) => { + if (err) { + // + // Database query failed + // + const queryError = promiseUtils.returnChainedError( + err, + httpStatus.BAD_GATEWAY, + 30005, + 'Failed to confirm default account' + ); + defer.resolve(queryError); + } else if (item === null) { + // + // Didn't find a matching account (doesn't exist, or + // doesn't belong to client). + // + const accountError = promiseUtils.returnChainedError( + err, + httpStatus.BAD_REQUEST, + 30006, + 'Invalid account id' + ); + defer.resolve(accountError); + } else { + // + // Item was found so it exists and belongs to this client + // + defer.resolve(); + } + }); + } else { + // + // Haven't asked to update default account, so nothing to check + // + defer.resolve(); + } + + // + // Wait for the account query (if any), then update the device + // + promise + .then(() => { + // + // Account verification passed (or was not needed) + // + const query = { + _id: mongodb.ObjectID(deviceId), // The device to update + ClientID: clientID // Must be *my* device + }; + + const updates = {$set: {}}; + if (deviceName !== undefined) { + updates.$set.DeviceName = deviceName; + } + if (defaultAccount !== undefined) { + // + // Special case: a `null` defaultAccount must be saved + // in the database as an empty string + // + if (defaultAccount === null) { + defaultAccount = ''; + } + updates.$set.DefaultAccount = defaultAccount; + } + + const options = { + upsert: false, + multi: false + }; + + mainDB.updateObject( + mainDB.collectionDevice, + query, + updates, + options, + false, + (err, result) => { + if (err) { + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30007, + info: 'Failed to update device' + }); + } else if (result.result.n === 0) { + // + // No documents matched the criteria + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30008, + info: 'Invalid device' + }); + } else { + res.status(httpStatus.OK).json(); + } + }); + }) + .catch((error) => { + // + // Handle any errors from account query + // + promiseUtils.sendErrorResponse(res, error); + }); +} + +/** + * Suspends the specified device so that it can't be used for transactions. + * The suspended device can be returned to service with `Resume()` + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function suspendDevice(req, res) { + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const deviceId = req.swagger.params.objectId.value; + + // + // Suspend the device (if it exists and belongs to me) + // + const query = { + _id: mongodb.ObjectID(deviceId), // The device to update + ClientID: clientID // Must be *my* device + }; + + const updates = { + $set: { + SessionToken: '' // Clear session token + }, + $bit: { + DeviceStatus: {or: utils.DeviceSuspendedMask} // Set suspended bits + } + }; + + const options = { + upsert: false, + multi: false + }; + + mainDB.updateObject( + mainDB.collectionDevice, + query, + updates, + options, + false, + (err, result) => { + if (err) { + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30009, + info: 'Database unavailable' + }); + } else if (result.result.n === 0) { + // + // No documents matched the criteria + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30008, + info: 'Invalid device' + }); + } else { + res.status(httpStatus.OK).json(); + } + }); +} + +/** + * Re-enables the suspended device so that it can be used for transactions again. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function resumeDevice(req, res) { + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const deviceId = req.swagger.params.objectId.value; + + // + // Resume access to the device (if it exists and belongs to me) + // + const query = { + _id: mongodb.ObjectID(deviceId), // The device to update + ClientID: clientID // Must be *my* device + }; + + const updates = { + $bit: { + // jshint -W016 + DeviceStatus: {and: ~utils.DeviceSuspendedMask} // clear suspended bits + // jshint +W016 + } + }; + + const options = { + upsert: false, + multi: false + }; + + mainDB.updateObject( + mainDB.collectionDevice, + query, + updates, + options, + false, + (err, result) => { + if (err) { + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30009, + info: 'Database unavailable' + }); + } else if (result.result.n === 0) { + // + // No documents matched the criteria + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30008, + info: 'Invalid device' + }); + } else { + res.status(httpStatus.OK).json(); + } + }); +} + +/** + * Deletes a device such that it can no longer be used in the system. + * What it actually does is copies the document to the DeviceArchive collection + * then deletes it from the Device collection. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function deleteDevice(req, res) { + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const deviceId = req.swagger.params.objectId.value; + + // + // Find the device we want to delete + // + const query = { + _id: mongodb.ObjectID(deviceId), // The device to update + ClientID: clientID // Must be *my* device + }; + const options = { + comment: 'WebConsole: find for deleteDevice' + }; + + // + // Step 1: Find the device. This includes checking ownership by the + // the user with the current session. + // + const findOriginalPromise = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionDevice, + query, + options, + false + ); + + // + // Step 2: Add a copy of the device document to the archive + // + const NOT_FOUND = 'BRIDGE: NOT FOUND'; + const DEVICE_BARRED = 'BRIDGE: DEVICE BARRED'; + let oldId = null; + const addArchivePromise = findOriginalPromise.then((item) => { + // + // DB query ran ok, but need to check if there were any results + // + if (!item) { + return Q.reject({name: NOT_FOUND}); + } else if ( + item && + (!item.hasOwnProperty('DeviceStatus') || + utils.bitsAllSet(item.DeviceStatus, utils.DeviceBarredMask)) + ) { + // Barred devices can't be deleted until unbarred + return Q.reject({name: DEVICE_BARRED}); + } else { + const insertObject = _.clone(item); + oldId = item._id; + insertObject.DeviceIndex = item._id.toString(); // Backup the old index + insertObject.DeviceAuthorisation = ''; + insertObject.DeviceSalt = ''; + insertObject.CurrentHMAC = ''; + insertObject.PendingHMAC = ''; + delete insertObject._id; // Then delete it to get a new one + + // + // Set LastUpdate to the current date + // + insertObject.LastUpdate = new Date(); + + // + // And insert into the database + // + return Q.nfcall( + mainDB.addObject, + mainDB.collectionDeviceArchive, + insertObject, + undefined, + false + ); + } + }); + + // + // Step 3: Delete the original + // + const NOT_ARCHIVED = 'BRIDGE: NOT ARCHIVED'; + const deleteOriginalPromise = addArchivePromise.then((result) => { + if (!_.isObject(result)) { + return Q.reject({name: NOT_ARCHIVED}); + } else { + debug('Deleting orginal: ', oldId); + const deleteQuery = { + _id: oldId + }; + return Q.nfcall( + mainDB.removeObject, + mainDB.collectionDevice, + deleteQuery, + undefined, + false + ); + } + }); + + // + // Run them all in sequence and check the result + // + Q.all([findOriginalPromise, addArchivePromise, deleteOriginalPromise]) + .then(() => { + // + // Succeeded + // + res.status(200).json(); + }) + .catch((error) => { + debug('-- error deleting device: ', error); + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case NOT_FOUND: + // + // Device not found in the DB (or doesn't belong to + // this user) + // + res.status(httpStatus.NOT_FOUND).json({ + code: 370, + info: 'Device not found' + }); + break; + + case DEVICE_BARRED: + // + // Device is barred + // + res.status(httpStatus.FORBIDDEN).json({ + code: 371, + info: 'Device barred' + }); + break; + + case NOT_ARCHIVED: + // + // Item failed to archive + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: 222, + info: 'Failed to archive device' + }); + break; + + case 'MongoError': + // + // Mongo Error + // + res.status(httpStatus.BAD_GATEWAY).json({ + code: 365, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }) + .done(); // Catch all +} + +/** + * Reports a device as lost, which suspends the device. This function is very + * similar to suspendDevice, except it is designed to be used by a user who + * does not have full permision to access the system - i.e. users that are + * waiting for a 2-factor confirmation that they can't give because they have + * lost their device. As they are not fully authorised we don't give them a + * list of devices to pick from. Instead we require them to pass in the full + * phone number of the device that should be suspended. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function reportLost(req, res) { + // + // Get the query params from the request and the session + // Note that we don't exclude devices that have already been suspended + // because it doesn't make any difference and could lead to confusing + // error messages. + // + const clientID = req.session.data.clientID; + const DeviceNumber = req.swagger.params.body.value.DeviceNumber; + + // + // Suspend the device (if it exists and belongs to me) + // + const query = { + ClientID: clientID, // Must be *my* device + DeviceNumber // Must have a matching number + }; + + const updates = { + $set: { + SessionToken: '' // Clear session token + }, + $bit: { + DeviceStatus: {or: utils.DeviceSuspendedMask} // Set suspended bits + } + }; + + const options = { + upsert: false, + multi: false + }; + + mainDB.updateObject( + mainDB.collectionDevice, + query, + updates, + options, + false, + (err, result) => { + if (err) { + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30401, + info: 'Database unavailable' + }); + } else if (result.result.n === 0) { + // + // No documents matched the criteria + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30402, + info: 'Invalid device' + }); + } else { + res.status(httpStatus.OK).json(); + } + }); +} + diff --git a/node_server/swagger_api/controllers/api_devices_controllers/api_addDevice.js b/node_server/swagger_api/controllers/api_devices_controllers/api_addDevice.js new file mode 100644 index 0000000..272d3f7 --- /dev/null +++ b/node_server/swagger_api/controllers/api_devices_controllers/api_addDevice.js @@ -0,0 +1,559 @@ +/** + * @fileOverview Node.js AddDevice Handler for Bridge Pay + * @preserve Copyright 2017 Comcarde Ltd. + * @author Keith Symington + * @see #bridge_server-core + * + * Handles the re-registration of an existing device. JSON version. + * @see {@link http://10.0.10.242/w/tricore_architecture/server_interface/registration_commands/adddevice/} + */ + +module.exports = { + addDevice +}; + +/** + * Includes + */ +const httpStatus = require('http-status-codes'); +const debug = require('debug')('webconsole-api:controllers:devices:addDevice'); + +const templates = require(global.pathPrefix + '../utils/templates.js'); +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const mainDBP = require(global.pathPrefix + 'mainDB-promises.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const deviceUtils = require(global.pathPrefix + '../utils/device/device.js'); +const log = require(global.pathPrefix + 'log.js'); +const sms = require(global.pathPrefix + 'sms-promises.js'); +const mailer = require(global.pathPrefix + 'mailer-promises.js'); +const credentialsUtil = require(global.pathPrefix + '../utils/credentials.js'); +const hashUtil = require(global.pathPrefix + '../utils/hashing.js'); +const config = require(global.configFile); +const apiUtils = require('../../api_utils.js'); + +/* eslint-disable complexity */ + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @param {!object} req - Request object. + * @param {!object} res - Response object for returning information. + */ +async function addDevice(req, res) { + // + // Get the query params from the request and the session + // + const connectionData = apiUtils.addPortAndAddress(req); + const remote = connectionData.remoteAddress; + const port = connectionData.protocolPort; + + const receivedBody = req.swagger.params.body.value; + + const clientName = receivedBody.ClientName; + const deviceNumber = receivedBody.DeviceNumber; + const password = receivedBody.Password; + const deviceHardware = receivedBody.DeviceHardware; + const deviceSoftware = receivedBody.DeviceSoftware; + const deviceUuid = receivedBody.DeviceUuid; + + let location; + + try { + if (receivedBody.Location) { + location = receivedBody.Location; + + if (location.coordinates[1] > 90 || + location.coordinates[1] < -90) { + throw utils.createError(-1, 'Request validation failed: Parameter (body) failed schema validation', httpStatus.BAD_REQUEST); + } + } + + /** + * Local variables. + */ + let htmlEmail; + let newPendingHMAC; + const registrationInformation = 'RI [' + clientName + ' (' + deviceNumber + ')]'; + + /** + * Valid add device request. + */ + log.system( + 'INFO', + 'Registration request received.', + 'AddDevice.process', + '', + registrationInformation, + (remote + ' (' + port + ')')); + + /** + * Local variables. + */ + const timestamp = new Date(); + let newRegistrationToken = ''; + const newRegistrationTokenExpiry = new Date(timestamp); + newRegistrationTokenExpiry.setHours(newRegistrationTokenExpiry.getHours() + utils.smsTokenDuration); + let newDeviceStatus = 0; + const newLastUpdate = new Date(timestamp); + + /** + * Never use the legacy test mode + */ + const TEST_MODE_OFF = null; + + /** + * First check to see if the client has an account. Retrieve if present. + */ + const existingClient = await mainDBP.findOneObjectPWithCode( + mainDB.collectionClient, + { + ClientName: clientName + }, + undefined, + false, + 331 + ); + + /** + * Second, retrieve any device information. + */ + const existingDevice = await mainDBP.findOneObjectPWithCode( + mainDB.collectionDevice, {DeviceNumber: deviceNumber}, undefined, false, 332 + ); + + /** + * No matching information found at all. + */ + if (existingClient === null) { + throw utils.createError(333, 'No client registration found.', httpStatus.UNAUTHORIZED); + } + + /** + * Device addition. First see how many devices the client has. + */ + if (existingDevice === null) { + const count = await mainDB.collectionDevice.find({ClientID: existingClient.ClientID}) + .count() + .catch(() => { + throw utils.createError(345, 'Database offline.', httpStatus.BAD_GATEWAY); + }); + + /** + * Only a limited number of devices are allowed on the account. + */ + if (count >= existingClient.MaxDevices) { + throw utils.createError(359, 'Maximum number of devices reached.', httpStatus.FORBIDDEN); + } + + /** + * Check the password. Throws on error, so nothing to check + */ + await validatePassword(clientName, password); + + /** + * Tell the user that a new device is being added - send an e-mail. + */ + htmlEmail = templates.render('device-added', { + DeviceNumber: deviceNumber + }); + await mailer.sendEmail(TEST_MODE_OFF, existingClient.ClientName, 'Bridge Device Addition', + htmlEmail, 'AddDevice.process' + ).catch(() => { + throw utils.createError(346, 'Unable to send e-mail.', httpStatus.SERVICE_UNAVAILABLE); + }); + + /** + * E -mail sent. Now set up the new device. + */ + const newDevice = mainDB.blankDevice(); + if (deviceHardware !== '') { // Add device hardware if sent. + newDevice.DeviceName = 'My ' + deviceHardware; + } + newDevice.DeviceUuid = deviceUuid; + newDevice.DeviceHardware = deviceHardware; + newDevice.DeviceSoftware = deviceSoftware; + newDevice.DeviceNumber = deviceNumber; + newDevice.ClientID = existingClient.ClientID; + newDevice.RegistrationToken = utils.randomCode(utils.numeric, utils.SMStokenLength); + newDevice.RegistrationTokenExpiry = newRegistrationTokenExpiry; + if (location !== null) { + newDevice.SignupLocation = location; + } + newDevice.SignupIP = remote; + newDevice.LastUpdate = new Date(timestamp); + newDevice.LastVersion = 1; + + // Generate a unique device token and check it doesn't exist. + newDevice.DeviceToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + + // Generate a unique device token and check it doesn't exist. + const tokenCheck = await mainDBP.findOneObjectPWithCode( + mainDB.collectionDevice, {DeviceToken: newDevice.DeviceToken}, undefined, false, 347); + + /** + * Ensure that the device token is unique - if not, log this and cancel registration. + */ + if (tokenCheck !== null) { + throw utils.createError(348, 'System error - token duplication.', httpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * All good. Add the device to the database. + */ + const addedDeviceObject = await mainDBP.addObjectPWithCode(mainDB.collectionDevice, newDevice, undefined, false, 349); + + /** + * Send the registration SMS if not in test mode. + */ + const smsBalance = await sms.sendSMS(TEST_MODE_OFF, newDevice.DeviceNumber, + ('Your Bridge verification code is ' + newDevice.RegistrationToken) + ).catch(() => { + throw utils.createError(350, 'SMS send failure.', httpStatus.SERVICE_UNAVAILABLE); + }); + + /** + * Success. + */ + debug( + 'Registration SMS sent (SMS balance now ' + smsBalance + ', ' + + (count + 1) + + '/' + + existingClient.MaxDevices + + ' devices).' + ); + res.status(httpStatus.CREATED).json({ + code: 10048, + info: 'AddDevice successful.', + DeviceToken: addedDeviceObject[0].DeviceToken, + DeviceID: addedDeviceObject[0]._id.toString() + }); + return; + } + + /** + * This is a device re-registration. + */ + if (existingDevice) { + /** + * Check the password. + */ + await validatePassword(clientName, password); + + /** + * Device is in use by someone else. + */ + if (existingClient.ClientID !== existingDevice.ClientID) { + throw utils.createError(338, 'This phone number is registered to somebody else.', httpStatus.FORBIDDEN); + } + + /** + * Device has been put on hold by Comcarde. + */ + if (utils.bitsAllSet(existingDevice.DeviceStatus, utils.DeviceBarredMask)) { + throw utils.createError(341, 'The device has been put on hold by Comcarde.', httpStatus.FORBIDDEN); + } + + /** + * Device has been suspended by the user. + */ + if (utils.bitsAllSet(existingDevice.DeviceStatus, utils.DeviceSuspendedMask)) { + throw utils.createError(342, 'The device has been suspended by the user.', httpStatus.FORBIDDEN); + } + + /** + * Occurs when the device is still waiting for the SMS code. Immediately shift to Register2. + * This code touches the SMS registration code to ensure it has not expired. + * DeviceToken resent as settings cleared down after a text message can lose token. + * Note that this does not increase the version. + */ + if (existingDevice.RegistrationToken !== '') { + await mainDBP.updateObjectPCheckObjectUpdated( + mainDB.collectionDevice, + { + DeviceNumber: existingDevice.DeviceNumber, + ClientID: existingClient.ClientID + }, + { + $set: { + RegistrationTokenExpiry: newRegistrationTokenExpiry, + LastUpdate: newLastUpdate + } + }, + {upsert: false}, + false, + 294 + ); + + /** + * Success + * Let the device know that it is waiting for the SMS code. + */ + res.status(httpStatus.OK).json({ + code: 10042, + info: 'Waiting for SMS code.', + DeviceToken: existingDevice.DeviceToken, + DeviceID: existingDevice._id.toString() + }); + return; + } + + /** + * Check to see if the DeviceUUid is the same. If so, there is little to do. + * Tell the user that the device has been re-registered. + */ + if (existingDevice.DeviceUuid === deviceUuid) { + htmlEmail = templates.render('device-re-registration', { + DeviceNumber: existingDevice.DeviceNumber + }); + await mailer.sendEmail( + TEST_MODE_OFF, + existingClient.ClientName, + 'Bridge Device Re-Registration', + htmlEmail, + 'AddDevice.process' + ) + .catch(() => { + throw utils.createError(11, 'Unable to send e-mail.', httpStatus.SERVICE_UNAVAILABLE); + }); + + /** + * HMAC needs to be reset. + */ + newPendingHMAC = utils.randomCode(utils.lowerCaseHex, (config.HMACBytes * 2)); + await mainDBP.updateObjectPCheckObjectUpdated(mainDB.collectionDevice, + { + DeviceNumber: existingDevice.DeviceNumber, + ClientID: existingClient.ClientID + }, + { + $set: { + PendingHMAC: newPendingHMAC, + CurrentHMAC: '', + LastUpdate: newLastUpdate + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, + false, + 445); + + /** + * E -mail sent. Let the user know that the device has been re-registered. + * There are different codes depending on whether the device was previously locked. + */ + if (existingDevice.LoginAttempts >= utils.PINLockout) { + res.status(httpStatus.OK).json({ + code: 10068, + info: 'Device re-registered - please reset PIN.', + DeviceToken: existingDevice.DeviceToken, + DeviceID: existingDevice._id.toString() + }); + return; + } else { + res.status(httpStatus.OK).json({ + code: 10039, + info: 'Device re-registered.', + DeviceToken: existingDevice.DeviceToken, + DeviceID: existingDevice._id.toString() + }); + return; + } + } else { + /** + * Hardware ID has been changed. Send text to confirm - this does de-authorise the other device + * by changing the device token. + */ + newRegistrationToken = utils.randomCode(utils.numeric, utils.SMStokenLength); + + /** + * Expected use of bitwise. + */ + // jshint -W016 + newDeviceStatus = existingDevice.DeviceStatus & (~utils.DeviceRegister2Mask); + // jshint +W016 + const newDeviceUuid = deviceUuid; + const newDeviceHardware = deviceHardware; + const newDeviceSoftware = deviceSoftware; + let newDeviceName = 'My Phone'; + if (deviceHardware !== '') { // Add device hardware if sent. + newDeviceName = 'My ' + deviceHardware; + } + + // Generate a unique device token and check it doesn't exist. + const newDeviceToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + const tokenCheck = await mainDBP.findOneObjectPWithCode( + mainDB.collectionDevice, + {DeviceToken: newDeviceToken}, + undefined, + false, + 298 + ); + + /** + * Oh dear - device token is not unique; log this and cancel registration. + */ + if (tokenCheck !== null) { + throw utils.createError(299, 'System error - token duplication.', httpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Archive the current device + */ + await deviceUtils.archiveDevice(existingDevice).catch(() => { + throw utils.createError(560, 'Database offline.', httpStatus.BAD_GATEWAY); + }); + + htmlEmail = templates.render('device-new-hardware', { + DeviceNumber: existingDevice.DeviceNumber + }); + await mailer.sendEmail(TEST_MODE_OFF, existingClient.ClientName, 'Bridge Device Hardware Signature Changed', + htmlEmail, 'AddDevice.process' + ).catch(() => { + throw utils.createError(282, 'Unable to send e-mail.', httpStatus.SERVICE_UNAVAILABLE); + }); + + /** + * All good. Update the database and notify the user. + */ + newPendingHMAC = utils.randomCode(utils.lowerCaseHex, (config.HMACBytes * 2)); + await mainDBP.updateObjectPCheckObjectUpdated(mainDB.collectionDevice, + { + DeviceNumber: existingDevice.DeviceNumber, + ClientID: existingClient.ClientID + }, + { + $set: { + DeviceToken: newDeviceToken, + RegistrationToken: newRegistrationToken, + RegistrationTokenExpiry: newRegistrationTokenExpiry, + DeviceUuid: newDeviceUuid, + DeviceName: newDeviceName, + DeviceHardware: newDeviceHardware, + DeviceSoftware: newDeviceSoftware, + DeviceStatus: newDeviceStatus, + SignupLocation: location, + PendingHMAC: newPendingHMAC, + CurrentHMAC: '', + LastUpdate: newLastUpdate + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, + false, + 281 + ); + + /** + * E-mail sent. Let the user know via SMS that the device hardware has been updated. + */ + const smsBalance = await sms.sendSMS(TEST_MODE_OFF, existingDevice.DeviceNumber, + ('Your Bridge verification code is ' + newRegistrationToken) + ).catch(() => { + throw utils.createError(283, 'SMS send failure.', httpStatus.SERVICE_UNAVAILABLE); + }); + + /** + * Success. + */ + debug('Registration SMS sent (SMS balance now ' + smsBalance); + res.status(httpStatus.CREATED).json({ + code: 10040, + info: 'Changing hardware ID.', + DeviceToken: newDeviceToken, + DeviceID: existingDevice._id.toString() + }); + } + } + } catch (error) { + res.status(error.httpCode).json({ + code: error.code, + info: error.message + }); + } +} + +/** + * Function to validate the password of the client. Returns a promise + * that resolves on success to the client object, and rejects on failure with + * an error code. See utils/hasher.js and utils/credentials.js for possible + * error codes. + * + * @param {string} email - the email address + * @param {string} password - the password + * + * @returns {promise} - resolves when the password is validated + */ +function validatePassword(email, password) { + /* Ignore warning about cylcomatic complexity */ + /* eslint-disable complexity */ + return credentialsUtil.validateRawPassword(email, password) + .then((result) => { + debug('= Validated: '); + return result; + }) + .catch((error) => { + debug('- Failed to validate: ', error); + + // + // Convert the error reason to the more limited set in use here + // + /* eslint-disable lines-around-comment */ + switch (error.toString()) { + // + // Actually not found, and password doesn't match + // are both called "No Match" to make it less obvious to + // an attacker + // + case credentialsUtil.ERRORS.NOT_FOUND: + case hashUtil.ERRORS.NO_MATCH: + debug('NO_MATCH'); + throw utils.createError( + 411, + 'Wrong password.', + httpStatus.UNAUTHORIZED + ); + + case credentialsUtil.ERRORS.BARRED: + throw utils.createError( + 117, + 'Client barred.', + httpStatus.FORBIDDEN + ); + + case credentialsUtil.ERRORS.TOO_MANY_ATTEMPTS: + throw utils.createError( + 406, + 'Account Locked', + httpStatus.FORBIDDEN + ); + case credentialsUtil.ERRORS.CANT_SEND_WARNING_EMAIL: + throw utils.createError( + 409, + 'Unable to send e-mail.', + httpStatus.INTERNAL_SERVER_ERROR + ); + // + // A number of different cases come down to server error + // + case credentialsUtil.ERRORS.CANT_UPDATE_ATTEMPTS_SUCCESS: + case credentialsUtil.ERRORS.CANT_UPDATE_ATTEMPTS_FAIL: + case hashUtil.ERRORS.UNKNOWN_ALGO: + case hashUtil.ERRORS.HASH_FAILED: + case hashUtil.ERRORS.SALT_FAILED: + throw utils.createError( + 408, + 'Database offline.', + httpStatus.BAD_GATEWAY + ); + + default: + // Also a server error + throw utils.createError( + 408, + 'Database offline.', + httpStatus.BAD_GATEWAY + ); + } + }); +} diff --git a/node_server/swagger_api/controllers/api_devices_controllers/api_setPin.js b/node_server/swagger_api/controllers/api_devices_controllers/api_setPin.js new file mode 100644 index 0000000..e2ae81d --- /dev/null +++ b/node_server/swagger_api/controllers/api_devices_controllers/api_setPin.js @@ -0,0 +1,197 @@ +/** + * @fileOverview Request handler for POST /devices/{objectId}/pin + * + * Sets the DeviceAuthorisation, makes the device active and adds GPS data. + */ + +module.exports = { + setPin +}; + +const httpStatus = require('http-status-codes'); +const mongodb = require('mongodb'); + +/** + * Includes + */ +const config = require(global.configFile); +const mainDBP = require(global.pathPrefix + 'mainDB-promises.js'); +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const log = require(global.pathPrefix + 'log.js'); +const references = require(global.pathPrefix + '../utils/references.js'); +const apiUtils = require('../../api_utils.js'); +const hashUtils = require('../../../utils/hashing.js'); + +/** + * Web Server elements. Processes an HTTP request, be it JSON or HTTP. + * For a full description of functionality, please see the link in fileOverview. + * + * @type {Function} process + * @param {!object} req - Request object. + * @param {!object} res - Response object for returning information. + */ +async function setPin(req, res) { + // + // Get the query params from the request and the session + // + const connectionData = apiUtils.addPortAndAddress(req); + const remote = connectionData.remoteAddress; + const port = connectionData.protocolPort; + const remotePort = (remote + ' (' + port + ')'); + + const deviceID = req.swagger.params.objectId.value; + + const receivedBody = req.swagger.params.body.value; + + const clientName = receivedBody.ClientName; + const deviceToken = receivedBody.DeviceToken; + const deviceAuthorisation = receivedBody.DeviceAuthorisation; + + const functionName = 'set-pin'; + const user = (clientName + ' (' + deviceToken + ')'); + + let location = null; + + if (receivedBody.Location) { + location = receivedBody.Location; + } + try { + /** + * Valid registration request. Find the device again. + */ + const existingDevice = await mainDBP.findOneObjectPWithCode(mainDB.collectionDevice, + { + _id: mongodb.ObjectID(deviceID), + DeviceToken: deviceToken + }, + undefined, false, 21); + + /** + * Ensure that a device was found. + */ + if (existingDevice === null) { + log.system( + 'WARNING', + 'Device Id or Device Token is invalid', + functionName, + '22', + user, + remotePort); + + throw utils.createError(22, 'Device Id, Device Token or Client Name is invalid', httpStatus.UNAUTHORIZED); + } + + let email; + + /** + * Check that the client we have been sent by the device is the correct + * one for that device + */ + try { + email = await references.getEmailAddress(existingDevice.ClientID); + } catch (error) { + if (error.name && error.name === references.ERRORS.INVALID_CLIENT) { + // No customer email found for this ID, so return error + log.system( + 'WARNING', + 'Client name does not match token.', + functionName, + '22', + user, + remotePort); + + throw utils.createError(22, 'Device Id, Device Token or Client Name is invalid', httpStatus.UNAUTHORIZED); + } else { + throw utils.createError(21, 'Database offline.', httpStatus.BAD_GATEWAY); + } + } + + /** + * Ensure that the e-mail addresses match. + */ + if (clientName !== email) { + log.system( + 'WARNING', + 'Client name does not match token.', + functionName, + '22', + user, + remotePort); + + throw utils.createError(22, 'Device Id, Device Token or Client Name is invalid', httpStatus.UNAUTHORIZED); + } + + /** + * This function only works on devices without a pin code. + */ + if (existingDevice.DeviceStatus !== utils.DeviceRegister2Mask) { + log.system( + 'WARNING', + 'Mobile device is in the wrong state (DeviceStatus=0x1).', + functionName, + '23', + user, + remotePort); + + throw utils.createError(23, 'Device not in verified state.', httpStatus.FORBIDDEN); + } + + /** + * Hash the password and store the salt. + */ + const newDeviceAuthorisation = await hashUtils.generateHash(Number(config.passwordCryptoVersion), deviceAuthorisation) + .catch(() => { + log.system( + 'WARNING', + 'Encryption error.', + functionName, + '414', + user, + remotePort); + + throw utils.createError(414, 'Encryption error.', httpStatus.INTERNAL_SERVER_ERROR); + }); + await mainDBP.updateObjectPCheckObjectUpdated(mainDB.collectionDevice, + { + _id: mongodb.ObjectID(deviceID), + DeviceToken: deviceToken, + DeviceStatus: utils.DeviceRegister2Mask + }, + { + $set: { + DeviceAuthorisation: newDeviceAuthorisation.hash, + DeviceSalt: newDeviceAuthorisation.salt, + SignupLocation: location + }, + $bit: { + DeviceStatus: {or: utils.DeviceRegister3Mask} + }, + $currentDate: { + LastUpdate: true + }, + $inc: {LastVersion: 1} + }, + {upsert: false}, + false, + 25 + ); + + /** + * Success. + */ + log.system( + 'INFO', + 'Device PIN set.', + functionName, + '10002', + user, + remotePort); + res.status(httpStatus.CREATED).json(); + } catch (error) { + res.status(error.httpCode).json({ + code: error.code, + info: error.message + }); + } +} diff --git a/node_server/swagger_api/controllers/api_devices_controllers/tests/api_addDevice.spec.js b/node_server/swagger_api/controllers/api_devices_controllers/tests/api_addDevice.spec.js new file mode 100644 index 0000000..8af3a59 --- /dev/null +++ b/node_server/swagger_api/controllers/api_devices_controllers/tests/api_addDevice.spec.js @@ -0,0 +1,1251 @@ +/** + * Unit testing file for ElevateSession command + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] */ +/* eslint-disable mocha/no-hooks-for-single-case */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../../../tools/test/testGlobals.js'); +const _ = require('lodash'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); +const httpStatus = require('http-status-codes'); +const mongodb = require('mongodb'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const addDevice = rewire('../api_addDevice.js'); + +const templatesStub = addDevice.__get__('templates'); +const mainDBStub = addDevice.__get__('mainDB'); +const mainDBPStub = addDevice.__get__('mainDBP'); +const utilsStub = addDevice.__get__('utils'); +const deviceUtilsStub = addDevice.__get__('deviceUtils'); +const logStub = addDevice.__get__('log'); +const smsStub = addDevice.__get__('sms'); +const mailerStub = addDevice.__get__('mailer'); +const configStub = addDevice.__get__('config'); +const credentialsUtilStub = addDevice.__get__('credentialsUtil'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +let findStub; // Modified below with a stub +let status; +let json; +let res; + +/** + * Define a sample Client and Device object to return + */ +const DEVICE_TOKEN = 'mno345'; +const CLIENT_EMAIL = 'a@example.com'; +const CLIENT_ID = '12345'; +const PASSWORD = '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8'; // "password" +const DEVICE_NUMBER = '+441234'; +const DEVICE_UUID = 'Unique and hidden identifier for the device'; +const DEVICE_HW = 'Some HW string'; +const DEVICE_SW = 'Some SW string'; +const DEVICE_LAT = 1.23; +const DEVICE_LONG = 4.56; +const DEVICE_MONGO_ID = '01234567890abcdef0123456'; +const MAX_DEVICES = 10; +const MAINDB_ERROR = 'Database offline.'; + +const RANDOM_REGISTRATION_TOKEN = 'ghi789'; +const RANDOM_PENDING_HMAC = 'jkl012'; + +const HTML_EMAIL = 'Some html'; + +const req = { + swagger: { + params: { + body: { + value: { + ClientName: CLIENT_EMAIL, + DeviceNumber: DEVICE_NUMBER, + Password: PASSWORD, + DeviceHardware: DEVICE_HW, + DeviceSoftware: DEVICE_SW, + DeviceUuid: DEVICE_UUID, + Location: { + type: 'Point', + coordinates: [DEVICE_LONG, DEVICE_LAT] + } + } + } + } + } +}; +const INVALID_LAT_REQ = { + swagger: { + params: { + body: { + value: { + Location: { + type: 'Point', + coordinates: [DEVICE_LONG, 91] + } + } + } + } + } +}; + +const CLIENT_FAKE = { + ClientName: CLIENT_EMAIL, + ClientID: CLIENT_ID, + MaxDevices: MAX_DEVICES +}; +const DEVICE_FAKE = { + _id: mongodb.ObjectID(DEVICE_MONGO_ID), + DeviceNumber: DEVICE_NUMBER, + DeviceUuid: DEVICE_UUID, + ClientID: CLIENT_ID, + DeviceStatus: utilsStub.DeviceFullyRegistered, + DeviceToken: DEVICE_TOKEN, + RegistrationToken: '', + LoginAttempts: 0 +}; + +/** + * Values for updating a device + */ +const NEW_DEVICE_TOKEN = 'abc123'; +const NEW_DEVICE_UUID = 'New Device Identifier'; +const NEW_DEVICE_HW = 'New HW string'; +const NEW_DEVICE_SW = 'New SW string'; +const NEW_DEVICE_LAT = 9.87; +const NEW_DEVICE_LONG = 8.76; + +/** + * Values for testing failures + */ +const NOT_CLIENT_EMAIL = 'not-a@example.com'; +const NOT_CLIENT_ID = 'abcdef'; + +const NEW_DEVICE = _.defaults( + { + DeviceToken: NEW_DEVICE_TOKEN + }, + DEVICE_FAKE, +); + +const DEVICE_NOT_MINE = _.defaults( + { + ClientID: NOT_CLIENT_ID + }, + DEVICE_FAKE, +); + +const DEVICE_BARRED = _.defaults( + { + DeviceStatus: utilsStub.DeviceBarredMask + }, + DEVICE_FAKE, +); + +const DEVICE_SUSPENDED = _.defaults( + { + DeviceStatus: utilsStub.DeviceSuspendedMask + }, + DEVICE_FAKE, +); + +const DEVICE_PENDING_CONFIRMATION = _.defaults( + { + DeviceStatus: 0, + RegistrationToken: RANDOM_REGISTRATION_TOKEN + }, + DEVICE_FAKE, +); + +const DEVICE_WAS_PIN_LOCKED = _.defaults( + { + LoginAttempts: utilsStub.PINLockout + }, + DEVICE_FAKE, +); + +/** + * Helpers for running the newly async command + */ +let callP; + +/** + * Call to addDevice.addDevice, and save the promise to `callP` so we can use + * it in the tests. + * + * @returns {Promise} - The promise for the addDevice + */ +function callAddDevice(thisReq) { + callP = addDevice.addDevice(thisReq, res); + return callP; +} + +/** + * Wrapper for mocha's `it` testcase function to wait for the result of the + * function before running the expectations. + * + * @param {string} description - The description for the test + * @param {Function} expectation - The expectation fucntion for this test + * + * @returns {Promise} - Promise for the completion of the test + */ +function itP(description, expectation) { + return it(description, () => { + return callP.then(() => expectation()); + }); +} + +/** + * Helper function for verifing the error response code + * + * @param {integer} code - the expected error code + * @param {string} info - the expected error description string + * @param {Object?} other - other parameters to expect + * + * @returns {any} - the result of the expect + */ +function expectResponse(code, info, other) { + const expected = _.merge( + { + code, + info + }, + other + ); + + return callP.then(() => expect(json).to.have.been + .calledOnce + .calledWithMatch( + expected + )); +} + +/** + * Helper function for verifing the http status code + * + * @param {integer} httpCode - the httpCode returned + * + * @returns {any} - the result of the expect + */ +function expectHttpCode(httpCode) { + return callP.then(() => expect(status).to.have.been + .calledOnce + .calledWithMatch( + httpCode + )); +} + +describe('AddDevice', () => { + /** + * Stub the functions that will be used for the "happy path" + * The responses are specifically overriden below for testing the error cases + */ + beforeEach(() => { + status = sandbox.stub(); + json = sandbox.spy(); + res = {json, + status}; + status.returns(res); + + sandbox.stub(credentialsUtilStub, 'validateRawPassword').resolves(); + sandbox.stub(logStub, 'system').returns(); + sandbox.stub(templatesStub, 'render').returns(HTML_EMAIL); + sandbox.stub(mailerStub, 'sendEmail').resolves(null); + sandbox.stub(smsStub, 'sendSMS').resolves(null); + + sandbox.stub(utilsStub, 'randomCode') + .onCall(0).returns(RANDOM_PENDING_HMAC) + .onCall(1).returns(RANDOM_REGISTRATION_TOKEN) + .onCall(2).returns(NEW_DEVICE_TOKEN); + + sandbox.stub(deviceUtilsStub, 'archiveDevice').resolves(); + + sandbox.stub(mainDBPStub, 'findOneObjectPWithCode') + .onCall(0).resolves(CLIENT_FAKE) // Find the client + .onCall(1).resolves(DEVICE_FAKE) // Find a device with the same number + .onCall(2).resolves(null); // Find a device with the same token + + sandbox.stub(mainDBPStub, 'addObjectPWithCode').resolves([NEW_DEVICE]); + sandbox.stub(mainDBPStub, 'updateObjectPCheckObjectUpdated').resolves(); + + // + // Stub for mainDB.collectionDevice is more complex because collectionDevice + // ins't initalised in the test environment + // + findStub = { + count: sandbox.stub().resolves(MAX_DEVICES - 1) + }; + const collectionStub = { + find: sandbox.stub().returns(findStub) + }; + mainDBStub.collectionDevice = collectionStub; + }); + + afterEach(() => { + mainDBStub.collectionDevice = null; + sandbox.restore(); + }); + + describe('basic failure cases', () => { + describe('invalid latitude value', () => { + beforeEach(() => { + callAddDevice(INVALID_LAT_REQ); + }); + itP('calls status with code 400', () => { + return expectHttpCode(httpStatus.BAD_REQUEST); + }); + + itP('returns error -1', () => { + return expectResponse(-1, 'Request validation failed: Parameter (body) failed schema validation'); + }); + }); + + describe('db error reading client', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(0).rejects({ + code: 331, + message: MAINDB_ERROR, + httpCode: httpStatus.BAD_GATEWAY + }); + callAddDevice(req); + }); + + itP('only trys to read client db', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match(mainDBStub.collectionClient), + sinon.match({ + ClientName: CLIENT_EMAIL + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(331) + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns error 331', () => { + return expectResponse(331, MAINDB_ERROR); + }); + }); + + describe('db error reading device', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(1).rejects({ + code: 332, + message: MAINDB_ERROR, + httpCode: httpStatus.BAD_GATEWAY + }); + callAddDevice(req); + }); + + itP('trys to read device db', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledTwice + .calledWith( + sinon.match(mainDBStub.collectionDevice), + sinon.match({ + DeviceNumber: DEVICE_NUMBER + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(332) + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns error 332', () => { + return expectResponse(332, MAINDB_ERROR); + }); + }); + + describe('client not found', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(0).resolves(null); + + const modifiedReq = _.clone(req); + modifiedReq.swagger.params.body.value.ClientName = NOT_CLIENT_EMAIL; + callAddDevice(modifiedReq); + }); + + itP('reads client and device db', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledTwice; + }); + + itP('calls status with code 401', () => { + return expectHttpCode(httpStatus.UNAUTHORIZED); + }); + + itP('returns error 333', () => { + return expectResponse(333, 'No client registration found.'); + }); + }); + }); // End basic failure cases + + describe('with no existing device matching the phone number', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(1).resolves(null); + }); + + describe('number of existing devices', () => { + describe('is checked but fails', () => { + beforeEach(() => { + findStub.count.rejects(); + callAddDevice(req); + }); + + itP('against the db', () => { + return expect(mainDBStub.collectionDevice.find).to.have.been + .calledOnce + .calledWith( + sinon.match({ + ClientID: CLIENT_ID + }) + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns error 345 if the database is unavailable', () => { + return expectResponse(345, MAINDB_ERROR); + }); + }); + + describe('is checked successfully', () => { + beforeEach(() => { + findStub.count.resolves(MAX_DEVICES); // Can't add 1 more + callAddDevice(req); + }); + + itP('against the db', () => { + return expect(mainDBStub.collectionDevice.find).to.have.been + .calledOnce + .calledWith( + sinon.match({ + ClientID: CLIENT_ID + }) + ); + }); + + itP('calls status with code 403', () => { + return expectHttpCode(httpStatus.FORBIDDEN); + }); + + itP('returns error 359 if client has max devices', () => { + return expectResponse(359, 'Maximum number of devices reached.'); + }); + }); + + describe('client password', () => { + beforeEach(() => { + credentialsUtilStub.validateRawPassword.rejects('Not found'); + + callAddDevice(req); + }); + + afterEach(() => { + mainDBStub.collectionDevice = null; + }); + + itP('is checked', () => { + return expect(credentialsUtilStub.validateRawPassword).to.have.been + .calledOnce + .calledWith( + sinon.match(req.swagger.params.body.value.ClientName), + sinon.match(req.swagger.params.body.value.Password) + ); + }); + + itP('calls status with code 401', () => { + return expectHttpCode(httpStatus.UNAUTHORIZED); + }); + + itP('return pass-through error from auth (e.g. 411) if its wrong', () => { + return expectResponse(411, 'Wrong password.'); + }); + }); + }); + + describe('sends an email notification', () => { + beforeEach(() => { + // Change the response to an error one + mailerStub.sendEmail.rejects(); + + callAddDevice(req); + }); + + itP('with a rendered email template', () => { + return expect(templatesStub.render).to.be + .calledOnce + .calledWith( + sinon.match('device-added'), + sinon.match({ + DeviceNumber: req.swagger.params.body.value.DeviceNumber + }) + ); + }); + + itP('using the mailer', () => { + return expect(mailerStub.sendEmail).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match(CLIENT_EMAIL), + sinon.match('Bridge Device Addition'), + sinon.match(HTML_EMAIL) + ); + }); + + itP('calls status with code 503', () => { + return expectHttpCode(httpStatus.SERVICE_UNAVAILABLE); + }); + + itP('returns 346 if sending email failed', () => { + return expectResponse(346, 'Unable to send e-mail.'); + }); + }); + + describe('creates a new device object', () => { + describe('if the database is offline while checking for uniqueness', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(2).rejects({ + code: 347, + message: MAINDB_ERROR, + httpCode: httpStatus.BAD_GATEWAY + }); + callAddDevice(req); + }); + itP('with random values for PendingHMAC, RegistrationToken, and DeviceToken', () => { + return callP.then(() => + expect(utilsStub.randomCode).to.have.been + .calledThrice + + // Check paramss for RegistrationToken + .calledWith( + sinon.match(utilsStub.numeric), + sinon.match(utilsStub.SMStokenLength) + ) + + // Check params for PendingHMAC and DeviceToken + .calledWith( + sinon.match(utilsStub.lowerCaseHex), + sinon.match(configStub.HMACBytes * 2) + ) + ); + }); + + itP('with DeviceToken checked against the db for uniquness', () => { + return callP.then(() => expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match(mainDBStub.collectionDevice), + sinon.match({ + DeviceToken: NEW_DEVICE_TOKEN + }) + ) + ); + }); + itP('calls status with code 503', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + itP('returns 347, Database offline', () => { + return expectResponse(347, MAINDB_ERROR); + }); + }); + describe('if the token is not unqiue', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(2).resolves({}); + callAddDevice(req); + }); + itP('with random values for PendingHMAC, RegistrationToken, and DeviceToken', () => { + return callP.then(() => + expect(utilsStub.randomCode).to.have.been + .calledThrice + + // Check paramss for RegistrationToken + .calledWith( + sinon.match(utilsStub.numeric), + sinon.match(utilsStub.SMStokenLength) + ) + + // Check params for PendingHMAC and DeviceToken + .calledWith( + sinon.match(utilsStub.lowerCaseHex), + sinon.match(configStub.HMACBytes * 2) + ) + ); + }); + + itP('with DeviceToken checked against the db for uniquness', () => { + return callP.then(() => expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match(mainDBStub.collectionDevice), + sinon.match({ + DeviceToken: NEW_DEVICE_TOKEN + }) + ) + ); + }); + itP('calls status with code 500', () => { + return expectHttpCode(httpStatus.INTERNAL_SERVER_ERROR); + }); + itP('returns 348, System error - token duplication.', () => { + return expectResponse(348, 'System error - token duplication.'); + }); + }); + }); + + describe('adds the device to the database', () => { + beforeEach(() => { + mainDBPStub.addObjectPWithCode.rejects({ + code: 349, + message: MAINDB_ERROR, + httpCode: httpStatus.BAD_GATEWAY + }); + + callAddDevice(req); + }); + + itP('adds to the clientDB', () => { + return expect(mainDBPStub.addObjectPWithCode).to.have.been + .calledOnce + .calledWith( + sinon.match(mainDBStub.collectionDevice), + sinon.match( + { + ClientID: CLIENT_ID, + CurrentHMAC: '', + DefaultAccount: '', + DeviceAuthorisation: sinon.match.string, + DeviceHardware: DEVICE_HW, + DeviceUuid: DEVICE_UUID, + DeviceName: 'My ' + DEVICE_HW, + DeviceNumber: DEVICE_NUMBER, + DeviceSalt: sinon.match.string, + DeviceSoftware: DEVICE_SW, + DeviceStatus: 0, + DeviceToken: NEW_DEVICE_TOKEN, + HMACAttempts: 0, + Integrity: null, + LastLogin: sinon.match.date, + LastLoginIP: '', + LastLoginLocation: null, + LastUpdate: sinon.match.date, + LastVersion: 1, + LoginAttempts: 0, + PendingHMAC: RANDOM_PENDING_HMAC, + RegistrationToken: RANDOM_REGISTRATION_TOKEN, + RegistrationTokenAttempts: 0, + RegistrationTokenExpiry: sinon.match.date, + SessionToken: '', + SessionTokenExpiry: sinon.match.date, + SignupLocation: sinon.match({ + coordinates: [ + DEVICE_LONG, + DEVICE_LAT + ], + type: 'Point' + }) + } + ) + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns 349 if the database is offline when trying to add', () => { + return expectResponse(349, MAINDB_ERROR); + }); + }); + + describe('sends a notification SMS', () => { + beforeEach(() => { + smsStub.sendSMS.rejects(); + callAddDevice(req); + }); + + itP('with the registration token', () => { + return expect(smsStub.sendSMS).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + DEVICE_NUMBER, + 'Your Bridge verification code is ' + RANDOM_REGISTRATION_TOKEN + ); + }); + + itP('calls status with code 503', () => { + return expectHttpCode(httpStatus.SERVICE_UNAVAILABLE); + }); + + itP('returns 350 if the SMS fails to send', () => { + return expectResponse(350, 'SMS send failure.'); + }); + }); + + describe('succeeds', () => { + beforeEach(() => { + callAddDevice(req); + }); + itP('calls status with code 201', () => { + return expectHttpCode(httpStatus.CREATED); + }); + itP('Succeeds with code 10048 (new device added), and returns DEVICE_TOKEN', () => { + return expectResponse( + 10048, + 'AddDevice successful.', + { + DeviceToken: NEW_DEVICE_TOKEN, + DeviceID: DEVICE_MONGO_ID + }); + }); + }); + }); + + describe('with an existing device matching the phone number', () => { + describe('client password', () => { + beforeEach(() => { + credentialsUtilStub.validateRawPassword.rejects('Regenerated hash not a match'); + + callAddDevice(req); + }); + + afterEach(() => { + mainDBStub.collectionDevice = null; + }); + + itP('is checked', () => { + return expect(credentialsUtilStub.validateRawPassword).to.have.been + .calledOnce + .calledWith( + sinon.match(CLIENT_EMAIL), + sinon.match(PASSWORD) + ); + }); + + itP('calls status with code 401', () => { + return expectHttpCode(httpStatus.UNAUTHORIZED); + }); + + itP('returns pass-through error from auth (e.g. 411) if its wrong', () => { + return expectResponse(411, 'Wrong password.'); + }); + }); + + describe('check the device can be updated', () => { + describe('is registered to someone else', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(1).resolves(DEVICE_NOT_MINE); + + callAddDevice(req); + }); + itP('calls status with code 403', () => { + return expectHttpCode(httpStatus.FORBIDDEN); + }); + itP('returns error 338, This phone number is registered to somebody else.', () => { + return expectResponse(338, 'This phone number is registered to somebody else.'); + }); + }); + + describe('is barred (by Comcarde)', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(1).resolves(DEVICE_BARRED); + + callAddDevice(req); + }); + + itP('calls status with code 403', () => { + return expectHttpCode(httpStatus.FORBIDDEN); + }); + + itP('returns error 341, The device has been put on hold by Comcarde.', () => { + return expectResponse(341, 'The device has been put on hold by Comcarde.'); + }); + }); + + describe('is suspended (by user)', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(1).resolves(DEVICE_SUSPENDED); + + callAddDevice(req); + }); + + itP('calls status with code 403', () => { + return expectHttpCode(httpStatus.FORBIDDEN); + }); + itP('returns error 342, The device has been suspended by the user.', () => { + return expectResponse(342, 'The device has been suspended by the user.'); + }); + }); + }); + + describe('which has a non-empty reg token', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(1).resolves(DEVICE_PENDING_CONFIRMATION); + }); + + describe('fail\'s to update the expiry time of the registration token', () => { + beforeEach(() => { + mainDBPStub.updateObjectPCheckObjectUpdated.rejects({ + code: 294, + message: MAINDB_ERROR, + httpCode: httpStatus.BAD_GATEWAY + }); + + callAddDevice(req); + }); + + itP('by calling the database', () => { + return expect(mainDBPStub.updateObjectPCheckObjectUpdated).to.have.been + .calledOnce + .calledWith( + mainDBStub.collectionDevice, + { + DeviceNumber: DEVICE_NUMBER, + ClientID: CLIENT_ID + }, + { + $set: { + RegistrationTokenExpiry: sinon.match.date, + LastUpdate: sinon.match.date + } + } + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns 294 if the database is offline during update', () => { + return expectResponse(294, MAINDB_ERROR); + }); + }); + + describe('succeeds', () => { + beforeEach(() => { + callAddDevice(req); + }); + + itP('calls status with code 200', () => { + return expectHttpCode(httpStatus.OK); + }); + + itP('succeeds with response 10042 (Enter six digit code)', () => { + return expectResponse( + 10042, + 'Waiting for SMS code.', + { + DeviceToken: DEVICE_TOKEN, + DeviceID: DEVICE_MONGO_ID + }); + }); + }); + }); + + describe('which has an empty reg token', () => { + describe('if the DeviceUuid DOES match', () => { + describe('fails to send an email notification', () => { + beforeEach(() => { + // Change the response to an error one + mailerStub.sendEmail.rejects(); + + callAddDevice(req); + }); + + itP('with a rendered email template', () => { + return expect(templatesStub.render).to.be + .calledOnce + .calledWith( + sinon.match('device-re-registration'), + sinon.match({ + DeviceNumber: req.swagger.params.body.value.DeviceNumber + }) + ); + }); + + itP('using the mailer', () => { + return expect(mailerStub.sendEmail).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match(CLIENT_EMAIL), + sinon.match('Bridge Device Re-Registration'), + sinon.match(HTML_EMAIL) + ); + }); + + itP('calls status with code 503', () => { + return expectHttpCode(httpStatus.SERVICE_UNAVAILABLE); + }); + + itP('returns 11 if sending email failed', () => { + return expectResponse(11, 'Unable to send e-mail.'); + }); + }); + + describe('failed to update the device\'s HMAC', () => { + beforeEach(() => { + mainDBPStub.updateObjectPCheckObjectUpdated.rejects({ + code: 445, + message: MAINDB_ERROR, + httpCode: httpStatus.BAD_GATEWAY + }); + + callAddDevice(req); + }); + + itP('by updating the database', () => { + return expect(mainDBPStub.updateObjectPCheckObjectUpdated).to.have.been + .calledOnce + .calledWith( + mainDBStub.collectionDevice, + { + DeviceNumber: DEVICE_NUMBER, + ClientID: CLIENT_ID + }, + { + $set: { + PendingHMAC: RANDOM_PENDING_HMAC, + CurrentHMAC: '', + LastUpdate: sinon.match.date + }, + $inc: { + LastVersion: 1 + } + } + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns 445 if the database is offline during update', () => { + return expectResponse(445, MAINDB_ERROR); + }); + }); + describe('succeeds', () => { + describe('if pin WAS NOT locked', () => { + beforeEach(() => { + callAddDevice(req); + }); + itP('calls status with code 200', () => { + return expectHttpCode(httpStatus.OK); + }); + itP('succeeds with response 10039 (Re-registered)', () => { + return expectResponse( + 10039, + 'Device re-registered.', + { + DeviceToken: DEVICE_TOKEN, + DeviceID: DEVICE_MONGO_ID + }); + }); + }); + describe('if pin WAS locked', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.onCall(1).resolves(DEVICE_WAS_PIN_LOCKED); + callAddDevice(req); + }); + itP('calls status with code 200', () => { + return expectHttpCode(httpStatus.OK); + }); + itP('succeeds with response 10068 (Reset pin)', () => { + return expectResponse( + 10068, + 'Device re-registered - please reset PIN.', + { + DeviceToken: DEVICE_TOKEN, + DeviceID: DEVICE_MONGO_ID + }); + }); + }); + }); + }); + + describe('if the DeviceUuid DOES NOT match', () => { + let testRequestNoMatch = {}; + beforeEach(() => { + testRequestNoMatch = + { + swagger: { + params: { + body: { + value: { + ClientName: CLIENT_EMAIL, + DeviceNumber: DEVICE_NUMBER, + Password: PASSWORD, + DeviceUuid: NEW_DEVICE_UUID, + DeviceSoftware: NEW_DEVICE_SW, + DeviceHardware: NEW_DEVICE_HW, + Location: { + type: 'Point', + coordinates: [NEW_DEVICE_LONG, NEW_DEVICE_LAT] + } + } + } + } + } + }; + + // + // The random tokens are generated in a different order here + // compared to creating a new object + // + utilsStub.randomCode + .onCall(0).returns(RANDOM_REGISTRATION_TOKEN) + .onCall(1).returns(NEW_DEVICE_TOKEN) + .onCall(2).returns(RANDOM_PENDING_HMAC); + }); + + describe('creates an update object for the new device description', () => { + itP('with random values for PendingHMAC, RegistrationToken, and DeviceToken', () => { + callAddDevice(testRequestNoMatch); + + return callP.then(() => + expect(utilsStub.randomCode).to.have.been + .calledThrice + + // Check paramss for RegistrationToken + .calledWith( + sinon.match(utilsStub.numeric), + sinon.match(utilsStub.SMStokenLength) + ) + + // Check params for PendingHMAC and DeviceToken + .calledWith( + sinon.match(utilsStub.lowerCaseHex), + sinon.match(configStub.HMACBytes * 2) + ) + ); + }); + + itP('with DeviceToken checked against the db for uniquness', () => { + callAddDevice(testRequestNoMatch); + + return callP.then(() => + expect(mainDBPStub.findOneObjectPWithCode).to.have.been + .calledThrice + .calledWith( + sinon.match(mainDBStub.collectionDevice), + sinon.match({ + DeviceToken: NEW_DEVICE_TOKEN + }) + ) + ); + }); + + itP('returns 298 if the database is offline while checking for uniqueness', () => { + mainDBPStub.findOneObjectPWithCode.onCall(2).rejects({ + code: 298, + message: MAINDB_ERROR, + httpCode: 502 + }); + callAddDevice(testRequestNoMatch); + + return expectResponse(298, MAINDB_ERROR); + }); + + itP('returns 299 if the token is not unqiue', () => { + mainDBPStub.findOneObjectPWithCode.onCall(2).resolves({}); + callAddDevice(testRequestNoMatch); + + return expectResponse(299, 'System error - token duplication.'); + }); + }); + + describe('archives the device', () => { + beforeEach(() => { + deviceUtilsStub.archiveDevice.rejects(); + callAddDevice(testRequestNoMatch); + }); + + itP('by calling utils function', () => { + return expect(deviceUtilsStub.archiveDevice).to.be + .calledOnce + .calledWith( + DEVICE_FAKE + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns 560 if the device cannot be archived', () => { + return expectResponse(560, MAINDB_ERROR); + }); + }); + + describe('sends an email notification', () => { + beforeEach(() => { + // Change the response to an error one + mailerStub.sendEmail.rejects(); + + callAddDevice(testRequestNoMatch); + }); + + itP('with a rendered email template', () => { + return expect(templatesStub.render).to.be + .calledOnce + .calledWith( + sinon.match('device-new-hardware'), + sinon.match({ + DeviceNumber: DEVICE_NUMBER + }) + ); + }); + + itP('using the mailer', () => { + return expect(mailerStub.sendEmail).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match(CLIENT_EMAIL), + sinon.match('Bridge Device Hardware Signature Changed'), + sinon.match(HTML_EMAIL) + ); + }); + + itP('calls status with code 503', () => { + return expectHttpCode(httpStatus.SERVICE_UNAVAILABLE); + }); + + itP('returns 282 if sending email failed', () => { + return expectResponse(282, 'Unable to send e-mail.'); + }); + }); + + describe('updates the device in the database', () => { + beforeEach(() => { + mainDBPStub.updateObjectPCheckObjectUpdated.rejects({ + code: 281, + message: MAINDB_ERROR, + httpCode: httpStatus.BAD_GATEWAY + }); + + callAddDevice(testRequestNoMatch); + }); + + itP('updates the clientDB', () => { + // + // Updating the device removes ONLY register2 status (i.e. it still + // treats it like the pin is set) + // + const EXPECTED_STATUS = 2; + return expect(mainDBPStub.updateObjectPCheckObjectUpdated).to.have.been + .calledOnce + .calledWith( + mainDBStub.collectionDevice, + { + DeviceNumber: DEVICE_NUMBER, + ClientID: CLIENT_ID + }, + sinon.match( + { + $set: sinon.match({ + DeviceToken: NEW_DEVICE_TOKEN, + RegistrationToken: RANDOM_REGISTRATION_TOKEN, + RegistrationTokenExpiry: sinon.match.date, + DeviceUuid: NEW_DEVICE_UUID, + DeviceName: 'My ' + NEW_DEVICE_HW, + DeviceHardware: NEW_DEVICE_HW, + DeviceSoftware: NEW_DEVICE_SW, + DeviceStatus: EXPECTED_STATUS, + SignupLocation: sinon.match({ + coordinates: [ + NEW_DEVICE_LONG, + NEW_DEVICE_LAT + ], + type: 'Point' + }), + PendingHMAC: RANDOM_PENDING_HMAC, + CurrentHMAC: '', + LastUpdate: sinon.match.date + }), + $inc: sinon.match({ + LastVersion: 1 + }) + } + ) + ); + }); + + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + + itP('returns 281 if the database is offline when trying to update', () => { + return expectResponse(281, MAINDB_ERROR); + }); + }); + + describe('sends a notification SMS', () => { + beforeEach(() => { + smsStub.sendSMS.rejects(); + callAddDevice(testRequestNoMatch); + }); + + itP('with the registration token', () => { + return expect(smsStub.sendSMS).to.have.been + .calledOnce + .calledWith( + sinon.match.any, + DEVICE_NUMBER, + 'Your Bridge verification code is ' + RANDOM_REGISTRATION_TOKEN + ); + }); + + itP('calls status with code 503', () => { + return expectHttpCode(httpStatus.SERVICE_UNAVAILABLE); + }); + + itP('returns 283 if the SMS fails to send', () => { + return expectResponse(283, 'SMS send failure.'); + }); + }); + + describe('succeeds', () => { + beforeEach(() => { + callAddDevice(testRequestNoMatch); + }); + itP('calls status with code 201', () => { + return expectHttpCode(httpStatus.CREATED); + }); + itP('Succeeds with code 10040 (new hardware id), and returns DEVICE_TOKEN', () => { + return expectResponse( + 10040, + 'Changing hardware ID.', + { + DeviceToken: NEW_DEVICE_TOKEN, + DeviceID: DEVICE_MONGO_ID + }); + }); + }); + }); + }); + }); +}); diff --git a/node_server/swagger_api/controllers/api_devices_controllers/tests/api_setPin.spec.js b/node_server/swagger_api/controllers/api_devices_controllers/tests/api_setPin.spec.js new file mode 100644 index 0000000..9b37b7c --- /dev/null +++ b/node_server/swagger_api/controllers/api_devices_controllers/tests/api_setPin.spec.js @@ -0,0 +1,519 @@ +/** + * Unit testing file for ElevateSession command + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] */ +/* eslint-disable mocha/no-hooks-for-single-case */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../../../tools/test/testGlobals.js'); +const _ = require('lodash'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); +const httpStatus = require('http-status-codes'); +const mongodb = require('mongodb'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const setPin = rewire('../api_setPin.js'); + +const mainDBPStub = setPin.__get__('mainDBP'); +const utilsStub = setPin.__get__('utils'); +const hashUtilsStub = setPin.__get__('hashUtils'); +const configStub = setPin.__get__('config'); +const referencesStub = setPin.__get__('references'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +let status; +let json; +let res; + +/** + * Define a sample Client and Device object to return + */ +const DEVICE_TOKEN = 'mno345'; +const CLIENT_EMAIL = 'a@example.com'; +const DATABASE_EMAIL = 'a@example.com'; +const DIFFERENT_DB_EMAIL = 'b@example.com'; +const DEVICE_LAT = 1.23; +const DEVICE_LONG = 4.56; +const DEVICE_AUTHORISATION = '12345'; +const MAINDB_ERROR = 'Database offline.'; +const CLIENT_ID = '12345'; +const NEW_SALT = 'asdfasdfa'; +const NEW_HASH = '2::24j234j2k4jn2'; +const DEVICE_ID = '123456789012345678901234'; + +const req = { + swagger: { + params: { + body: { + value: { + ClientName: CLIENT_EMAIL, + DeviceToken: DEVICE_TOKEN, + DeviceAuthorisation: DEVICE_AUTHORISATION, + Location: { + type: 'Point', + coordinates: [DEVICE_LONG, DEVICE_LAT] + } + } + }, + objectId: { + value: DEVICE_ID + } + } + } +}; +const DEVICE_FAKE = { + ClientID: CLIENT_ID, + DeviceStatus: utilsStub.DeviceRegister2Mask, + DeviceToken: DEVICE_TOKEN +}; +const DEVICE_NOT_VERIFIED_DEVICE_FAKE = { + ClientID: CLIENT_ID, + DeviceStatus: utilsStub.DeviceRegister3Mask, + DeviceToken: DEVICE_TOKEN +}; + +/** + * Helpers for running the newly async command + */ +let callP; + +/** + * Call to setPin.setPin, and save the promise to `callP` so we can use + * it in the tests. + * + * @returns {Promise} - The promise for the setPin + */ +function callSetPin(thisReq) { + callP = setPin.setPin(thisReq, res); + return callP; +} + +/** + * Wrapper for mocha's `it` testcase function to wait for the result of the + * function before running the expectations. + * + * @param {string} description - The description for the test + * @param {Function} expectation - The expectation fucntion for this test + * + * @returns {Promise} - Promise for the completion of the test + */ +function itP(description, expectation) { + return it(description, () => { + return callP.then(() => expectation()); + }); +} + +/** + * Helper function for verifing the error response code + * + * @param {integer} code - the expected error code + * @param {string} info - the expected error description string + * @param {Object?} other - other parameters to expect + * + * @returns {any} - the result of the expect + */ +function expectResponse(code, info, other) { + const expected = _.merge( + { + code, + info + }, + other + ); + + return callP.then(() => expect(json).to.have.been + .calledOnce + .calledWithMatch( + expected + )); +} + +/** + * Helper function for verifing the http status code + * + * @param {integer} httpCode - the httpCode returned + * + * @returns {any} - the result of the expect + */ +function expectHttpCode(httpCode) { + return callP.then(() => expect(status).to.have.been + .calledOnce + .calledWithMatch( + httpCode + )); +} + +describe('SetPin', () => { + /** + * Stub the functions that will be used for the "happy path" + * The responses are specifically overriden below for testing the error cases + */ + beforeEach(() => { + status = sandbox.stub(); + json = sandbox.spy(); + res = {json, + status}; + status.returns(res); + + sandbox.stub(mainDBPStub, 'findOneObjectPWithCode').resolves(DEVICE_FAKE); // Find the device + sandbox.stub(referencesStub, 'getEmailAddress').resolves(DATABASE_EMAIL); + sandbox.stub(mainDBPStub, 'updateObjectPCheckObjectUpdated').resolves(); + sandbox.stub(hashUtilsStub, 'generateHash').resolves( + { + salt: NEW_SALT, + hash: NEW_HASH + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('succesfully set pin', () => { + beforeEach(() => { + callSetPin(req); + }); + itP('finds the device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match( + { + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('encrypts the pin', () => { + return expect(hashUtilsStub.generateHash).to.be + .calledOnce + .calledWith( + sinon.match(Number(configStub.passwordCryptoVersion), DEVICE_AUTHORISATION) + ); + }); + itP('updates device object', () => { + return expect(mainDBPStub.updateObjectPCheckObjectUpdated).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match( + { + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN, + DeviceStatus: utilsStub.DeviceRegister2Mask + }), + sinon.match({ + $set: { + DeviceAuthorisation: NEW_HASH, + DeviceSalt: NEW_SALT, + SignupLocation: { + type: 'Point', + coordinates: [DEVICE_LONG, DEVICE_LAT] + } + }, + $bit: { + DeviceStatus: {or: utilsStub.DeviceRegister3Mask} + }, + $currentDate: { + LastUpdate: true + }, + $inc: {LastVersion: 1} + }), + sinon.match({upsert: false}), + sinon.match(false), + sinon.match(25) + ); + }); + itP('calls status with code 201', () => { + return expectHttpCode(httpStatus.CREATED); + }); + }); + describe('basic failure cases', () => { + describe('database is offline while finding device', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.rejects({ + code: 21, + message: MAINDB_ERROR, + httpCode: 502 + }); + callSetPin(req); + }); + itP('try\'s to find device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({ + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + itP('returns 21, database is offline', () => { + return expectResponse(21, MAINDB_ERROR); + }); + }); + describe('Device token and/or Device Id are invalid', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.resolves(null); + callSetPin(req); + }); + itP('fails to find device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({ + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('calls status with code 401', () => { + return expectHttpCode(httpStatus.UNAUTHORIZED); + }); + itP('returns 22, Device Id, Device Token or Client Name is invalid', () => { + return expectResponse(22, 'Device Id, Device Token or Client Name is invalid'); + }); + }); + describe('No client found for device', () => { + beforeEach(() => { + referencesStub.getEmailAddress.rejects({ + name: referencesStub.ERRORS.INVALID_CLIENT + }); + callSetPin(req); + }); + itP('finds the device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({ + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('try\'s to get the email address', () => { + return expect(referencesStub.getEmailAddress).to.be + .calledOnce + .calledWith( + sinon.match(DEVICE_FAKE.ClientID) + ); + }); + itP('calls status with code 401', () => { + return expectHttpCode(httpStatus.UNAUTHORIZED); + }); + itP('22, Device Id, Device Token or Client Name is invalid', () => { + return expectResponse(22, 'Device Id, Device Token or Client Name is invalid'); + }); + }); + describe('database is offline while finding email address', () => { + beforeEach(() => { + referencesStub.getEmailAddress.rejects(); + callSetPin(req); + }); + itP('try\'s to get the email address', () => { + return expect(referencesStub.getEmailAddress).to.be + .calledOnce + .calledWith( + sinon.match(DEVICE_FAKE.ClientID) + ); + }); + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + itP('returns 21, database is offline', () => { + return expectResponse(21, MAINDB_ERROR); + }); + }); + describe('Email doesn\'t match devices client\'s email', () => { + beforeEach(() => { + referencesStub.getEmailAddress.resolves(DIFFERENT_DB_EMAIL); + callSetPin(req); + }); + itP('finds the device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({ + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('gets the email address', () => { + return expect(referencesStub.getEmailAddress).to.be + .calledOnce + .calledWith( + sinon.match(DEVICE_FAKE.ClientID) + ); + }); + itP('calls status with code 401', () => { + return expectHttpCode(httpStatus.UNAUTHORIZED); + }); + itP('22, Device Id, Device Token or Client Name is invalid', () => { + return expectResponse(22, 'Device Id, Device Token or Client Name is invalid'); + }); + }); + describe('Mobile device is not in verified state', () => { + beforeEach(() => { + mainDBPStub.findOneObjectPWithCode.resolves(DEVICE_NOT_VERIFIED_DEVICE_FAKE); + callSetPin(req); + }); + itP('finds device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({ + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('calls status with code 403', () => { + return expectHttpCode(httpStatus.FORBIDDEN); + }); + itP('returns 23, Device not in verified state.', () => { + return expectResponse(23, 'Device not in verified state.'); + }); + }); + describe('Encryption error when encrypting pin', () => { + beforeEach(() => { + hashUtilsStub.generateHash.rejects(); + callSetPin(req); + }); + itP('finds the device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({ + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('try\'s to encrypt the pin', () => { + return expect(hashUtilsStub.generateHash).to.be + .calledOnce + .calledWith( + sinon.match(Number(configStub.passwordCryptoVersion), DEVICE_AUTHORISATION) + ); + }); + itP('calls status with code 500', () => { + return expectHttpCode(httpStatus.INTERNAL_SERVER_ERROR); + }); + itP('returns 414 if the client email is invalid', () => { + return expectResponse(414, 'Encryption error.'); + }); + }); + describe('database is offline while updating device object', () => { + beforeEach(() => { + mainDBPStub.updateObjectPCheckObjectUpdated.rejects({ + code: 25, + message: MAINDB_ERROR, + httpCode: 502 + }); + callSetPin(req); + }); + itP('finds the device', () => { + return expect(mainDBPStub.findOneObjectPWithCode).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match({ + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN + }), + sinon.match(undefined), + sinon.match(false), + sinon.match(21) + ); + }); + itP('encrypts the pin', () => { + return expect(hashUtilsStub.generateHash).to.be + .calledOnce + .calledWith( + sinon.match(Number(configStub.passwordCryptoVersion), DEVICE_AUTHORISATION) + ); + }); + itP('try\'s to update device object', () => { + return expect(mainDBPStub.updateObjectPCheckObjectUpdated).to.be + .calledOnce + .calledWith( + sinon.match.any, + sinon.match( + { + _id: mongodb.ObjectID(DEVICE_ID), + DeviceToken: DEVICE_TOKEN, + DeviceStatus: utilsStub.DeviceRegister2Mask + }), + sinon.match({ + $set: { + DeviceAuthorisation: NEW_HASH, + DeviceSalt: NEW_SALT, + SignupLocation: { + type: 'Point', + coordinates: [DEVICE_LONG, DEVICE_LAT] + } + }, + $bit: { + DeviceStatus: {or: utilsStub.DeviceRegister3Mask} + }, + $currentDate: { + LastUpdate: true + }, + $inc: {LastVersion: 1} + }), + sinon.match({upsert: false}), + sinon.match(false), + sinon.match(25) + ); + }); + itP('calls status with code 502', () => { + return expectHttpCode(httpStatus.BAD_GATEWAY); + }); + itP('returns 25, Database offline', () => { + return expectResponse(25, MAINDB_ERROR); + }); + }); + }); +}); diff --git a/node_server/swagger_api/controllers/api_invoices_controller.js b/node_server/swagger_api/controllers/api_invoices_controller.js new file mode 100644 index 0000000..edd7aa0 --- /dev/null +++ b/node_server/swagger_api/controllers/api_invoices_controller.js @@ -0,0 +1,1077 @@ +/** + * Controller to manage the invoices functions + */ +'use strict'; + +const _ = require('lodash'); +const Q = require('q'); +const httpStatus = require('http-status-codes'); +const mongodb = require('mongodb'); + +const templates = require(global.pathPrefix + '../utils/templates.js'); +const debug = require('debug')('webconsole-api:controllers:invoices'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const mailer = require(global.pathPrefix + 'mailer.js'); +const valid = require(global.pathPrefix + 'valid.js'); +const swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); +const apiHelpers = require(global.pathPrefix + '../utils/api_helpers.js'); +const formattingUtils = require(global.pathPrefix + '../utils/formatting.js'); +const references = require(global.pathPrefix + '../utils/references.js'); +const config = require(global.configFile); + +module.exports = { + getInvoices, + getInvoice, + addInvoice, + updateInvoice, + cancelInvoice +}; + +/** + * Define a constant for the valid "invoice" transaction statuses + */ +const INVOICE_TRANSACTION_STATUSES = [20, 21, 22]; + +/** + * Definition for the renames we use between "Transactions" and "Invoices" + */ +const INVOICE_TO_TRANSACTION = { + _id: 'InvoiceID', + CustomerClientName: 'CustomerEmail', + TransactionStatus: 'InvoiceStatus' +}; + +/** + * Validation errors + */ +const ERRORS = { + NO_MERCHANT_ACCOUNT: 'BRIDGE: Merchant account not found', + NO_CUSTOMER: 'BRIDGE: Customer not found', + INSERT_INVOICE_INVALID_NUMBER: 'BRIDGE: Failed to find a valid invoice number' +}; + +/** + * Get the invoice list + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getInvoices(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 30701, + info: 'Not a merchant' + }); + return; + } + + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + + // + // Define the query according to the params + // + const query = { + MerchantClientID: clientID, + TransactionStatus: { + $in: INVOICE_TRANSACTION_STATUSES + } + }; + + // + // Define the projection based on the Swagger definition + // + const projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true, // include _id so we know how to select an individual invoice. + undefined, // No subdocument + INVOICE_TO_TRANSACTION // Renames + ); + + // + // Make the query. Note limit & skip have defaults defined in the + // swagger definition, so will always exist even if not requested + // + mainDB.collectionTransaction.find(query) + .project(projection) + .sort({LastUpdate: -1}) // Hard-coded reverse sort by time + .toArray((err, invoices) => { + if (err) { + debug('- failed to getInvoices', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30702, + info: 'Database offline' + }); + } else { + // + // Rename _id to InvoiceID before returning them + // Rename CustomerClientName to CustomerEmail + // + apiHelpers.renameFields(invoices, INVOICE_TO_TRANSACTION); + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, invoices); + + // + // Move invoice number to the top level + // Fix any missing due dates + // + for (let i = 0; i < invoices.length; ++i) { + if (!_.isUndefined(invoices[i].MerchantInvoiceNumber)) { + invoices[i].MerchantInvoiceNumber = + invoices[i].MerchantInvoiceNumber.InvoiceNumber; + } + if (_.isUndefined(invoices[i].DueDate)) { + invoices[i].DueDate = '1970-01-01T00:00:00.000Z'; + } + } + res.status(httpStatus.OK).json(invoices); + } + }); +} + +/** + * Gets the invoice details for a specific invoice. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getInvoice(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 30703, + info: 'Not a merchant' + }); + return; + } + + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const invoiceId = req.swagger.params.objectId.value; + + // + // Build the query. The limits are: + // - Must match the id of the invoice we are looking for + // - Current user must be the invoice owner (to protect against Insecure + // Direct Object References). + // + const query = { + _id: mongodb.ObjectID(invoiceId), + MerchantClientID: clientID, + TransactionStatus: { + $in: INVOICE_TRANSACTION_STATUSES + } + }; + + // + // Define the fields based on the Swagger definition. + // Need to also request the CustomerClientName + // + const projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true, + undefined, // No subdocument + INVOICE_TO_TRANSACTION // Renames + ); + + // + // Add the CustomerClientID so we can find out the email address later + // + projection.CustomerClientID = 1; + + // + // Build the options to encapsulate the projection + // + const options = { + fields: projection, + comment: 'WebConsole:getInvoice' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionTransaction, query, options, false, + (err, invoice) => { + if (err) { + debug('- failed to getInvoice', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30704, + info: 'Database offline' + }); + } else if (invoice === null) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30705, + info: 'Not found' + }); + } else { + // + // Get the email address for the client + // + const emailP = references.getEmailAddress(invoice.CustomerClientID); + delete invoice.CustomerClientID; + + // + // Add a creation date field from the _id + // + invoice.CreationDate = invoice._id.getTimestamp(); + + // + // Rename fields + // + apiHelpers.renameFields(invoice, INVOICE_TO_TRANSACTION); + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, invoice); + + // + // Move the invoice number to the top level, and fix potentially + // missing due date + // + if (!_.isUndefined(invoice.MerchantInvoiceNumber)) { + invoice.MerchantInvoiceNumber = + invoice.MerchantInvoiceNumber.InvoiceNumber; + } + if (_.isUndefined(invoice.DueDate)) { + invoice.DueDate = '1970-01-01T00:00:00.000Z'; + } + + // + // Wait for the client email address and complete the invoice + // + emailP.then((email) => { + invoice.CustomerEmail = email; + return res.status(httpStatus.OK).json(invoice); + }).catch((error) => { + if (error.name && error.name === references.ERRORS.INVALID_CLIENT) { + // No customer email, so just send it without one. + // This will allow the merchant to update the customer, cancel it, etc. + res.status(httpStatus.OK).json(invoice); + } else { + debug('- failed to get customer email', error); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30704, + info: 'Database offline' + }); + } + }); + } + }); +} + +/** + * Adds a new invoice. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function addInvoice(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 30706, + info: 'Not a merchant' + }); + return; + } + + const merchantID = req.session.data.clientID; + const validatedInvoice = req.swagger.params.body.value; + + // + // Step 0: Validate the merchant invoice values add up correctly + // + const INVALID_MERCHANT_INVOICE = 'BRIDGE: MerchantInvoice items are not valid'; + let invoiceValidationP = Q.resolve(); + if (_.isArray(validatedInvoice.MerchantInvoice)) { + /** + * Validate the invoice, allowing for items to be repeated + */ + const result = valid.validateFieldMerchantInvoice( + validatedInvoice.MerchantInvoice, + validatedInvoice.RequestAmount, + true + ); + if (result) { + invoiceValidationP = Q.reject({name: INVALID_MERCHANT_INVOICE}); + } + } + + // + // Step 1: Get the merchant's client details + // + const NO_MERCHANT = 'BRIDGE: Merchant not found'; + const findMerchantQuery = { + ClientID: merchantID + }; + const findMerchantOptions = { + comment: 'webconsole: addInvoice validate merchant' + }; + const findMerchantPromise = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + findMerchantQuery, + findMerchantOptions, + false // Don't suppress errors + ).then((merchant) => { + return merchant ? merchant : Q.reject({name: NO_MERCHANT}); + }); + + // + // Step 2: Validate the merchant's account ID + // + const findAccountPromise = validateMerchantAccount( + merchantID, + validatedInvoice.MerchantAccountID + ); + + // + // Step 3: Validate the customer. + // + const findCustomerPromise = validateCustomer(validatedInvoice.CustomerEmail); + + // + // Step 4: Check everything is valid, so now we create the Transactions we are + // going to make. + // + const addPromise = Q.all([ + invoiceValidationP, + findMerchantPromise, + findAccountPromise, + findCustomerPromise]).then((results) => { + const merchantDetails = results[1]; + const customer = results[3]; + + // + // Create a blank transaction, then update it with our specific values + // + const newTransaction = mainDB.blankTransaction(); + _.assignWith( + newTransaction, + { + CustomerClientID: customer.ClientID, + CustomerDisplayName: customer.DisplayName, + CustomerImage: customer.Selfie, + MerchantSessionToken: req.session.id, + MerchantAccountID: validatedInvoice.MerchantAccountID, + DueDate: validatedInvoice.DueDate, + MerchantClientID: merchantID, + MerchantDisplayName: merchantDetails.Merchant[0].CompanyAlias, + MerchantSubDisplayName: merchantDetails.Merchant[0].CompanySubName, + MerchantImage: merchantDetails.Merchant[0].CompanyLogo, + MerchantVATNo: merchantDetails.Merchant[0].VATNo, + MerchantInvoice: validatedInvoice.MerchantInvoice, + MerchantComment: validatedInvoice.MerchantComment, + TransactionStatus: utils.TransactionStatus.PENDING_INVOICE, + StatusInfo: 'Pending Invoice', + RequestAmount: validatedInvoice.RequestAmount, + LastUpdate: new Date(), + + // Invoice Numbering + MerchantInvoiceNumber: { + InvoiceNumber: 1, + MerchantID: merchantID, + MerchantIndex: 0 // Always 0 at present, but allows future support + } + }, + (objectValue, sourceValue) => { + /* Only merge values that aren't received as undefined */ + return _.isUndefined(sourceValue) ? objectValue : sourceValue; + } + ); + + // + // Built up the transaction so add it. + // + return addMonotonicallyNumberedInvoice( + newTransaction, + config.maxInvoiceNumberAttempts + ); + }); + + // + // Step 4. Run all the promises and wait for the result + // + Q.all([findMerchantPromise, findAccountPromise, findCustomerPromise, addPromise]) + .then((result) => { + // + // Succeeded + // The _id is in result[2][0] because: + // Result is an array of results from the 3 promises in .all() + // Thus result[2] is the result of addPromise + // This is an addObject() which returns an array itself. But we + // are only adding one so we know it is result[2][0]. + // + const insertedInvoice = result[3][0]; + res.status(201).json({ + InvoiceID: insertedInvoice._id + }); + + // + // Send an email to the customer + // Note that we are not going to let the success/failure affect + // the success of adding an invoice + // + return notifyNewInvoice(validatedInvoice.CustomerEmail, insertedInvoice); + }) + .catch((error) => { + debug('-- error adding invoice: ', error); + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case ERRORS.NO_MERCHANT_ACCOUNT: + res.status(httpStatus.CONFLICT).json({ + code: 30708, + info: 'Invalid Merchant Account' + }); + break; + + case ERRORS.NO_CUSTOMER: + res.status(httpStatus.CONFLICT).json({ + code: 30709, + info: 'Customer not found' + }); + break; + + case ERRORS.INSERT_INVOICE_INVALID_NUMBER: + res.status(httpStatus.CONFLICT).json({ + code: 30718, + info: 'Unable to find a valid invoice number.' + }); + break; + + case 'MongoError': + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30707, + info: 'Database Unavailable' + }); + break; + + case INVALID_MERCHANT_INVOICE: + res.status(httpStatus.BAD_REQUEST).json({ + code: 30718, + info: 'Invalid MerchantInvoice values' + }); + break; + + case NO_MERCHANT: // This should never happen as we have a session + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }); +} + +/** + * This attempts to add a new invoice with an ID that is monotonically + * increasing for each invoice. As we don't have db transactions we can't + * do something like have a merchant-level counter we increment and apply to + * invoices. + * + * Instead we have to use an "optimistic loop" together with a unique index on + * the collection. See: + * https://docs.mongodb.com/v3.0/tutorial/create-an-auto-incrementing-field/#auto-increment-optimistic-loop + * + * The basic operation is: + * 1. Query for the highest current invoice number + * 2. Add 1 to it, and try and insert document with that invoice number + * 3. If (2) fails due to duplicate index THEN goto 1 (e.g. race condition with + * another process which is also adding invoices) + * 4. If (2) fails times then report an error (overloading or similar). + * + * @param {Object} newInvoice - the new invoice we want to insert + * @param {integer} maxAttempts - the max attempts we can make + * + * @returns {Promise} - a promise that resolves when the operation completes + * or rejects on failure + */ +function addMonotonicallyNumberedInvoice(newInvoice, maxAttempts) { + debug('addMonotonicallyNumberedInvoice', maxAttempts, newInvoice.MerchantInvoiceNumber.MerchantID); + + // + // Have we run out of attempts? + // + if (maxAttempts === 0) { + return Q.reject({name: ERRORS.INSERT_INVOICE_INVALID_NUMBER}); + } + + // + // Try and find a valid number to insert with + // + const query = { + 'MerchantInvoiceNumber.MerchantID': newInvoice.MerchantInvoiceNumber.MerchantID, + 'MerchantInvoiceNumber.MerchantIndex': newInvoice.MerchantInvoiceNumber.MerchantIndex + }; + const sortOrder = { + 'MerchantInvoiceNumber.InvoiceNumber': -1 + }; + const projection = { + MerchantInvoiceNumber: 1 + }; + + // + // Run the query to find the largest number + // + debug('addMonotonicallyNumberedInvoice: finding: ', query, projection); + const cursor = mainDB.collectionTransaction + .find(query, projection) + .sort(sortOrder) + .limit(1); + + const findPromise = cursor.next(); + + // + // Use the result of that query to try and insert a new invoice. + // This could fail if we are racing another insertion, so we need to expect + // that posibility, and try again + // + const insertPromise = findPromise.then((result) => { + debug('- addMonotonicallyNumberedInvoice: found:', result); + + let nextNumber = 1; + if (result) { // result === null if no entries match the query + nextNumber = result.MerchantInvoiceNumber.InvoiceNumber + 1; + } + newInvoice.MerchantInvoiceNumber.InvoiceNumber = nextNumber; + + return Q.nfcall( + mainDB.addObject, + mainDB.collectionTransaction, + newInvoice, + undefined, // No options + true // Expect errors, so don't kill the DB if we get any + ).catch((error) => { + debug('- addMonotonicallyNumberedInvoice: insertError:', error); + + // + // This may or may not be an expected error + // + if (error.name === 'MongoError' && error.code === 11000) { + // + // This is the duplicate unqiue key error we expect might + // happen during a race condition. So try again with 1 less + // retry limit + return addMonotonicallyNumberedInvoice(newInvoice, maxAttempts - 1); + } else { + // + // Its some other error, so pass it on + // + return Q.reject(error); + } + }); + }); + + // + // Return the insertPromise so the caller can wait until its done + // + return insertPromise; +} + +/** + * Updates an invoice with new values. Note that this can only be done for + * Invoices in the RejectedInvoice state. PendingInvoices should be cancelled + * and re-submitted. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function updateInvoice(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 30710, + info: 'Not a merchant' + }); + return; + } + + const merchantClientID = req.session.data.clientID; + const validatedBody = req.swagger.params.body.value; + const invoiceId = req.swagger.params.objectId.value; + const resubmit = req.swagger.params.resubmit.value; + + // + // Step 1: Validate the customer and merchant account + // + const findAccountPromise = validateMerchantAccount( + merchantClientID, + validatedBody.MerchantAccountID + ); + const findCustomerPromise = validateCustomer(validatedBody.CustomerEmail); + + // + // Step 2: Setup the find query. Limitations: + // - Must belong to me as merchant + // - Must be the given id + // - Must be in the Pending or Rejected state + // + const findQuery = { + MerchantClientID: merchantClientID, + _id: mongodb.ObjectID(invoiceId), + TransactionStatus: { + $in: [ + utils.TransactionStatus.PENDING_INVOICE, + utils.TransactionStatus.REJECTED_INVOICE + ] + } + }; + + // + // Step 3: Setup the update parameters from what we have been given. + // + + const update = { + $set: {}, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + const required = ['MerchantAccountID', 'DueDate', 'RequestAmount']; + const optional = ['MerchantInvoice', 'MerchantComment']; + let idx = 0; + for (idx = 0; idx < required.length; ++idx) { + update.$set[required[idx]] = validatedBody[required[idx]]; + } + for (idx = 0; idx < optional.length; ++idx) { + const key = optional[idx]; + if (validatedBody.hasOwnProperty(key) && validatedBody[key] !== null) { + update.$set[key] = validatedBody[key]; + } + } + + if (resubmit) { + update.$set.TransactionStatus = utils.TransactionStatus.PENDING_INVOICE; + } + + const options = { + projection: { + _id: 1, + CustomerClientName: 1, // Needed for email notification + MerchantDisplayName: 1 // Needed for email notification + }, + upsert: false, + returnOriginal: false // Need the updated value, not the old one + }; + + // + // Step 4. Check that we validated everything ok, then Run the findAndUpdate + // + const updatePromise = Q.all([findAccountPromise, findCustomerPromise]) + .then((results) => { + const customer = results[1]; + + // + // CustomerClientID is special as we have to wait to find it from + // the customer data. We also need to update the display name at + // the same time + // + update.$set.CustomerClientID = customer.ClientID; + update.$set.CustomerDisplayName = customer.DisplayName; + + return Q.ninvoke( + mainDB.collectionTransaction, + 'findOneAndUpdate', + findQuery, + update, + options + ); + }); + + // + // Step 5. Check everything ran ok, then respond as appropriate + // + Q.all([findAccountPromise, findCustomerPromise, updatePromise]) + .then((results) => { + // + // Ran the operation successfully, but need to check if it actually + // updated anything. + // + const updateResult = results[2]; + if (updateResult.value) { + res.status(200).json(); + + // + // Notify the customer that the invoice has been updated. + // Note that we don't make the result contingent on the email + // sending correctly + // + return notifyUpdatedInvoice( + validatedBody.CustomerEmail, + updateResult.value + ); + } else { + return res.status(404).json({ + code: 30714, + info: 'Invoice not found, or not in Pending or Rejected state' + }); + } + }) + .catch((error) => { + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case ERRORS.NO_MERCHANT_ACCOUNT: + res.status(httpStatus.CONFLICT).json({ + code: 30712, + info: 'Invalid Merchant Account' + }); + break; + + case ERRORS.NO_CUSTOMER: + res.status(httpStatus.CONFLICT).json({ + code: 30713, + info: 'Customer not found' + }); + break; + + case 'MongoError': + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30711, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }); +} + +/** + * Cancels an invoice (moving the transaction into the Cancelled status + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function cancelInvoice(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 30715, + info: 'Not a merchant' + }); + return; + } + + const merchantClientID = req.session.data.clientID; + const invoiceId = req.swagger.params.objectId.value; + + // + // Step 1: Setup the find query. Limitations: + // - Must belong to me as merchant + // - Must be the given id + // - Must not be a pending or rejected invoice + // + const findQuery = { + MerchantClientID: merchantClientID, + _id: mongodb.ObjectID(invoiceId), + TransactionStatus: { + $in: [ + utils.TransactionStatus.PENDING_INVOICE, + utils.TransactionStatus.REJECTED_INVOICE + ] + } + }; + + // + // Step 2: Setup the update parameters from what we have been given. + // + const update = { + $set: { + TransactionStatus: utils.TransactionStatus.CANCELLED_INVOICE + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + const options = { + projection: { + _id: 1, + + // Needed for the email notification + MerchantDisplayName: 1, + MerchantInvoiceNumber: 1, + CustomerClientID: 1 + }, + upsert: false + }; + + // + // Step 3. Run the findAndUpdate + // + Q.ninvoke( + mainDB.collectionTransaction, + 'findOneAndUpdate', + findQuery, + update, + options + ) + .then((result) => { + // + // Ran the operation successfully, but need to check if it actually + // updated anything. + // + if (result.value) { + res.status(200).json(); + + // + // Notify the customer that the invoice has been cancelled + // + return notifyCancelledInvoice( + result.value.CustomerClientID, + result.value + ); + } else { + return res.status(404).json({ + code: 30717, + info: 'Invoice not found, or already Cancelled' + }); + } + }) + .catch((error) => { + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case 'MongoError': + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30716, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }); +} + +/** + * Validates that the merchant account exists, and belongs to this user + * + * @param {string} merchantID - the merchant's client id + * @param {string} accountId - the account Id of the merchant account to use + * + * @returns {Promise} - a promise for finding the account + */ +function validateMerchantAccount(merchantID, accountId) { + const findAccountQuery = { + _id: mongodb.ObjectID(accountId), + ClientID: merchantID, + ReceivingAccount: 1, + AccountStatus: { + $bitsAllClear: utils.AccountDeleted + } + }; + const findAccountOptions = { + fields: { + _id: 1, + ClientID: 1 + }, + comment: 'webconsole: add/updateInvoice validate account' + }; + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionAccount, + findAccountQuery, + findAccountOptions, + false // Don't suppress errors + ).then((account) => { + return account ? account : Q.reject({name: ERRORS.NO_MERCHANT_ACCOUNT}); + }); +} + +/** + * Validates that the customer email points to a real customer + * + * @param {string} customerEmail - The customer's email address + * + * @returns {Promise} - A promise for finding the customer + */ +function validateCustomer(customerEmail) { + const findClientQuery = { + ClientName: customerEmail, + ClientStatus: {$bitsAllClear: utils.ClientBarredMask} + }; + const findClientOptions = { + fields: { + _id: 1, + ClientID: 1, + ClientName: 1, + DisplayName: 1, + Selfie: 1 + }, + comment: 'webconsole: addInvoice validate account' + }; + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + findClientQuery, + findClientOptions, + false // Don't suppress errors + ).then((client) => { + return client ? client : Q.reject({name: ERRORS.NO_CUSTOMER}); + }); +} + +/** + * Notifies the customer that a new invoice has been raised against them. + * + * @param {string} customerEmail - the customer's email address + * @param {Object} invoice - the invoice (for adding info to the email) + * + * @returns {Promise} - a promise for the result of notifying the customer + */ +function notifyNewInvoice(customerEmail, invoice) { + /** + * Render the html for the email + */ + const htmlEmail = templates.render('invoice-new', { + merchant: invoice.MerchantDisplayName, + requestAmount: formattingUtils.formatMoney(invoice.RequestAmount) + }); + + return Q.nfcall( + mailer.sendEmail, + '', // Mode ('Test' to just log, anything else to send) + customerEmail, // Destination + 'New Invoice', // Subject + htmlEmail, + 'notifyNewInvoice' + ); +} + +/** + * Notifies the customer that an existing invoice has been updated. + * + * @param {string} customerEmail - the customer's email address + * @param {Object} invoice - the invoice (for adding info to the email) + * + * @returns {Promise} - a promise for the result of notifying the customer + */ +function notifyUpdatedInvoice(customerEmail, invoice) { + /** + * Render the html for the email + */ + const htmlEmail = templates.render('invoice-updated', { + merchant: invoice.MerchantDisplayName + }); + + return Q.nfcall( + mailer.sendEmail, + '', // Mode ('Test' to just log, anything else to send) + customerEmail, // Destination + 'Updated Invoice', // Subject + htmlEmail, + 'notifyUpdatedInvoice' + ); +} + +/** + * Notifies the customer that an existing invoice has been cancelled by the merchant. + * + * @param {string} customerID - the customer's client ID + * @param {Object} invoice - the invoice (for adding info to the email) + * + * @returns {Promise} - a promise for the result of notifying the customer + */ +function notifyCancelledInvoice(customerID, invoice) { + /** + * Render the html for the email + */ + const htmlEmail = templates.render('invoice-cancelled', { + merchant: invoice.MerchantDisplayName, + number: invoice.MerchantInvoiceNumber.InvoiceNumber + }); + + return Q.nfcall( + mailer.sendEmailByID, + '', // Mode ('Test' to just log, anything else to send) + customerID, // Destination + 'Cancelled Invoice', // Subject + htmlEmail, + 'notifyCancelledInvoice' + ); +} diff --git a/node_server/swagger_api/controllers/api_items_controller.js b/node_server/swagger_api/controllers/api_items_controller.js new file mode 100644 index 0000000..3aabaef --- /dev/null +++ b/node_server/swagger_api/controllers/api_items_controller.js @@ -0,0 +1,712 @@ +/** + * Controller to manage the items functions + */ +'use strict'; + +const _ = require('lodash'); +const Q = require('q'); +const httpStatus = require('http-status-codes'); +const mongodb = require('mongodb'); +const debug = require('debug')('webconsole-api:controllers:items'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); +const responsesUtils = require(global.pathPrefix + '../utils/responses.js'); +const config = require(global.configFile); + +module.exports = { + getItems, + getItem, + addItems, + updateItem, + deleteItem +}; + +/** + * Get the item list + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getItems(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 470, + info: 'Not a merchant' + }); + return; + } + + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const includeDeleted = req.swagger.params.includeDeleted.value; + const bridgeId = req.swagger.params.BridgeID.value; + + // + // Define the query according to the params + // + const query = { + ClientID: clientID + }; + if (!includeDeleted) { + query.ItemStatus = utils.ItemStatusActive; // Only active items + } + if (bridgeId) { + query.BridgeID = bridgeId; // Only this id + } + + // + // Define the projection based on the Swagger definition + // + const projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true // include _id so we know how to select an individual item. + ); + + // + // Make the query. Note limit & skip have defaults defined in the + // swagger definition, so will always exist even if not requested + // + mainDB.collectionItems.find(query, projection) + .sort({LastUpdate: -1}) // Hard-coded reverse sort by time + .toArray((err, items) => { + if (err) { + debug('- failed to getItems', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 469, + info: 'Database offline' + }); + } else { + // + // Rename _id to ItemID before returning them + // + _.forEach( + items, + (value) => { + value.ItemID = value._id; + delete value._id; + }); + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, items); + res.status(httpStatus.OK).json(items); + } + }); +} + +/** + * Gets the item details for a specific item. Note that this will also allow + * access to deleted items if needed. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getItem(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 470, + info: 'Not a merchant' + }); + return; + } + + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const itemId = req.swagger.params.objectId.value; + + // + // Build the query. The limits are: + // - Must match the id of the item we are looking for + // - Current user must be the item owner (to protect against Insecure + // Direct Object References). + // + const query = { + _id: mongodb.ObjectID(itemId), + ClientID: clientID + }; + + // + // Define the fields based on the Swagger definition. + // + const projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + true + ); + + // + // Build the options to encapsulate the projection + // + const options = { + fields: projection, + comment: 'WebConsole:getItem' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionItems, query, options, false, + (err, item) => { + if (err) { + debug('- failed to getItem', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30601, + info: 'Database offline' + }); + } else if (item === null) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30602, + info: 'Not found' + }); + } else { + // + // Rename _id to ItemId + // + item.ItemID = item._id; + delete item._id; + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, item); + + res.status(httpStatus.OK).json(item); + } + }); +} + +/** + * Adds a new item. Note that this is always considered a new item and given + * a new BridgeID. If you want to add a version to an existing product then use + * updateItem() (POST /items/{ItemID} + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function addItems(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 470, + info: 'Not a merchant' + }); + return; + } + + const clientID = req.session.data.clientID; + const validatedItems = req.swagger.params.body.value; + const itemsCount = validatedItems.length; + + // + // Step 1: Find all existing active items + // + const findQuery = { + ClientID: clientID, + ItemStatus: utils.ItemStatusActive + }; + + const findPromise = mainDB.collectionItems.find(findQuery).toArray(); + + // + // Step 2: Check existing items and confirm that we don't have too many, + // and that we don't have any duplicate item ids. + // + const MAX_ITEMS = 'BRIDGE: MAX ITEMS'; + const CODE_IN_USE = 'BRIDGE: CODE IN USE'; + const INVALID_AMOUNTS = 'BRIDGE: INVALID AMOUNTS'; + const checkPromise = findPromise.then((results) => { + if ((results.length + itemsCount) >= config.maxItems) { + return Q.reject({name: MAX_ITEMS}); + } + + /** + * Check if we have have any duplicate ItemCodes in the items + */ + let all = results.concat(validatedItems); + all = _.filter(all, 'ItemCode'); // Ignore empty strings + + const uniqAll = _.uniqBy(all, 'ItemCode'); + if (uniqAll.length < all.length) { + return Q.reject({name: CODE_IN_USE}); + } + + /** + * Check one of Net and Gross is a number, and the other one is a null + */ + const validAmounts = validatedItems.every((item) => + (_.isNull(item.NetAmount) && _.isNumber(item.GrossAmount)) || + (_.isNumber(item.NetAmount) && _.isNull(item.GrossAmount)) + ); + if (validAmounts === false) { + return Q.reject({name: INVALID_AMOUNTS}); + } + + return true; + }); + + const addPromise = checkPromise.then(() => { + // + // Step 3: Add the new items. As this is a new item we also create a + // brand new random BridgeID for it. + // + const newItems = []; + for (let i = 0; i < itemsCount; ++i) { + const validatedItem = validatedItems[i]; + const newItem = { + BridgeID: utils.timeBasedRandomCode(), + ClientID: clientID, + ImageID: null, + ItemStatus: utils.ItemStatusActive, + LastUpdate: new Date(), + LastVersion: 1 + }; + const required = ['Description', 'VATRate', 'NetAmount', 'GrossAmount']; + const optional = ['ItemCode', 'VATCode', 'ImageID', 'Tags', 'LoyaltyPoints']; + let idx = 0; + for (idx = 0; idx < required.length; ++idx) { + newItem[required[idx]] = validatedItem[required[idx]]; + } + for (idx = 0; idx < optional.length; ++idx) { + const key = optional[idx]; + if (validatedItem.hasOwnProperty(key) && validatedItem[key] !== null) { + newItem[key] = validatedItem[key]; + } else if (key === 'Tags') { + newItem.Tags = []; // Tags is an array + } else if (key === 'LoyaltyPoints') { + newItem.LoyaltyPoints = null; // Loyalty points default to null + } else { + newItem[key] = ''; + } + } + newItems.push(newItem); + } + + return mainDB.collectionItems.insertMany(newItems); + }); + + // + // Step 4. Run all the promises and wait for the result + // + Q.all([findPromise, checkPromise, addPromise]) + .then((result) => { + // + // Succeeded + // The _id is in result[2][0] because: + // Result is an array of results from the 3 promises in .all() + // Thus result[2] is the result of addPromise + // This is an addObject() which returns an array itself. But we + // are only adding one so we know it is result[2][0]. + // + return res.status(201).json({ + ItemID: result[2].insertedIds + }); + }) + .catch((error) => { + debug('-- error adding item: ', error); + const responses = [ + [ + MAX_ITEMS, + httpStatus.CONFLICT, 30603, 'Max items reached', true + ], + [ + CODE_IN_USE, + httpStatus.CONFLICT, 30608, 'Duplicate ItemCodes not allowed.', true + ], + [ + INVALID_AMOUNTS, + httpStatus.BAD_REQUEST, 30609, + 'Only one of NetAmount and GrossAmount can be set.', true + ], + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 30605, 'Database Unavailable', true + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }); +} + +/** + * Updates an item by creating a new version of this item with the given + * parameters, using the BridgeID of the item passed in the url + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function updateItem(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 470, + info: 'Not a merchant' + }); + return; + } + + const clientID = req.session.data.clientID; + const validatedBody = req.swagger.params.body.value; + const itemId = req.swagger.params.objectId.value; + + // + // Step 1: Find the specified item to get the BridgeID. Note that this + // may be deleted, so we don't ignore that. + // + const findQuery = { + ClientID: clientID, + _id: mongodb.ObjectID(itemId) + }; + const findPromise = Q.nfcall(mainDB.findOneObject, mainDB.collectionItems, findQuery, undefined, false); + + // + // Step 2: Find all existing active items + // + const findAllQuery = { + ClientID: clientID, + ItemStatus: utils.ItemStatusActive + }; + + const findAllPromise = mainDB.collectionItems.find(findAllQuery).toArray(); + + // + // Step 3: Check that: + // (a) we found the item we were asked to update. + // (b) we don't have too many active items. + // + const ITEM_NOT_FOUND = 'BRIDGE: SOURCE ITEM FOR UPDATE NOT FOUND'; + const MAX_ITEMS = 'BRIDGE: MAX ITEMS'; + const CODE_IN_USE = 'BRIDGE: CODE IN USE'; + const INVALID_AMOUNTS = 'BRIDGE: INVALID AMOUNTS'; + const checkPromise = Q.all([findPromise, findAllPromise]).then((results) => { + // + // Check the item exists + // + const item = results[0]; + if (!item) { + return Q.reject({name: ITEM_NOT_FOUND}); + } + + // + // Check we don't have too many active items. Note that we check for + // existing items being > than the count rather than >= because we + // are only replacing not adding. + // + const items = results[1]; + if (results.length > config.maxItems) { + return Q.reject({name: MAX_ITEMS}); + } + + // + // Check we aren't updating the ItemCode to one that already exists. + // Note that we need to ignore the item we are updating, or ignore the + // check entirely if we don't have an item code in the update. + // + if (validatedBody.ItemCode) { + const thisItemIdString = item._id.toString(); + const duplicate = _.find( + items, + (testItem) => { + return testItem._id.toString() !== thisItemIdString && + testItem.ItemCode === validatedBody.ItemCode; + } + ); + if (duplicate) { + return Q.reject({name: CODE_IN_USE}); + } + } + + /** + * Check one of Net and Gross is a number, and the other one is a null + */ + const validAmounts = + (_.isNull(validatedBody.NetAmount) && _.isNumber(validatedBody.GrossAmount)) || + (_.isNumber(validatedBody.NetAmount) && _.isNull(validatedBody.GrossAmount)); + if (validAmounts === false) { + return Q.reject({name: INVALID_AMOUNTS}); + } + + return item; + }); + + // + // Step 4: Add the new item. As this is a new version of an existing item + // we will use the existing BridgeID. This is done inside the + // then() function from the previous promise as the value needs + // to come from the database. + // + const newItem = { + ClientID: clientID, + ImageID: null, + ItemStatus: utils.ItemStatusActive, + LastUpdate: new Date(), + LastVersion: 1 + }; + const required = ['Description', 'VATRate', 'NetAmount', 'GrossAmount']; + const optional = ['ItemCode', 'VATCode', 'ImageID', 'Tags', 'LoyaltyPoints']; + let idx = 0; + for (idx = 0; idx < required.length; ++idx) { + newItem[required[idx]] = validatedBody[required[idx]]; + } + for (idx = 0; idx < optional.length; ++idx) { + const key = optional[idx]; + if (validatedBody.hasOwnProperty(key) && validatedBody[key] !== null) { + newItem[key] = validatedBody[key]; + } else if (key === 'Tags') { + newItem.Tags = []; // Tags is an array + } else if (key === 'LoyaltyPoints') { + newItem.LoyaltyPoints = null; // Loyalty points default to null + } else { + newItem[key] = ''; + } + } + + const addPromise = checkPromise.then((item) => { + newItem.BridgeID = item.BridgeID; + return Q.nfcall( + mainDB.addObject, + mainDB.collectionItems, + newItem, + undefined, + false + ); + }); + + // + // Step 5: Set the previously active version(s) to deleted status + // + const deleteQuery = { + ItemStatus: utils.ItemStatusActive + }; + const deleteUpdates = { + $set: { + ItemStatus: utils.ItemStatusDeleted + }, + $currentDate: { + LastUpdate: true + }, + $inc: { + LastVersion: 1 + } + }; + const deleteOptions = { + upsert: false, + multi: true, + comment: 'WebConsole: deleteItem' + }; + + const deletePromise = addPromise.then((addResult) => { + // Update the query based on the item we've just added: + // - Only update other versions of this BridgeID + // - Don't soft-delete the item we've just added + const addedItem = addResult[0]; + deleteQuery.BridgeID = addedItem.BridgeID; + deleteQuery._id = { + $ne: addedItem._id + }; + + // + // Run the update query. Note that it is acceptable for this not + // to delete anything if we were editing an item that has no active + // version. This will essentially active a deleted item. + // + return Q.nfcall( + mainDB.updateObject, + mainDB.collectionItems, + deleteQuery, + deleteUpdates, + deleteOptions, + false + ); + }); + + // + // Step 6. Run all the promises and wait for the result + // + Q.all([findPromise, findAllPromise, checkPromise, addPromise, deletePromise]) + .then((result) => { + // + // Succeeded + // The _id is in result[3][0] because: + // Result is an array of results from all promises + // Thus result[3] is the result of addPromise + // This is an addObject() which returns an array itself. But we + // are only adding one so we know it is result[3][0]. + // + return res.status(201).json({ + ItemID: result[3][0]._id + }); + }) + .catch((error) => { + const responses = [ + [ + ITEM_NOT_FOUND, + httpStatus.NOT_FOUND, 30606, 'Item to update not found', true + ], + [ + MAX_ITEMS, + httpStatus.CONFLICT, 30603, 'Max items reached', true + ], + [ + CODE_IN_USE, + httpStatus.CONFLICT, 30608, 'Duplicate ItemCodes not allowed.', true + ], + [ + INVALID_AMOUNTS, + httpStatus.BAD_REQUEST, 30609, + 'Only one of NetAmount and GrossAmount can be set.', + true + ], + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 30605, 'Database Unavailable', true + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }); +} + +/** + * Deletes a Item such that it can no longer be used in the system. + * What it actually does is set the ItemStatus to Deleted, so it is still + * accessible for historical reasons. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function deleteItem(req, res) { + // + // Check that the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 470, + info: 'Not a merchant' + }); + return; + } + + // + // Get the query params from the request and the session + // + const clientID = req.session.data.clientID; + const itemId = req.swagger.params.objectId.value; + + // + // Find and update the item we want to "delete" + // + const query = { + _id: mongodb.ObjectID(itemId), // The Item to update + ClientID: clientID // Must be *my* Item + }; + + const updates = { + $set: { + LastUpdate: new Date(), + ItemStatus: utils.ItemStatusDeleted + }, + $inc: { + LastVersion: 1 + } + }; + + const options = { + upsert: false, + multi: false, + comment: 'WebConsole: deleteItem' + }; + + const FAILED_UPDATE = 'BRIDGE: Failed to update item to deleted status'; + const updateP = Q.nfcall( + mainDB.updateObject, + mainDB.collectionItems, + query, + updates, + options, + false + ).then((results) => { + if (results.result.n === 0) { + return Q.reject({name: FAILED_UPDATE}); + } else { + return Q.resolve(); + } + }); + + // + // Check the result + // + updateP + .then(() => { + // + // Succeeded + // + return res.status(200).json(); + }) + .catch((error) => { + debug('-- error deleting Item: ', error); + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case FAILED_UPDATE: + // + // Item not found in the DB (or doesn't belong to + // this user) + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30606, + info: 'Item not found' + }); + break; + + case 'MongoError': + // + // Mongo Error + // + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30607, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }); +} diff --git a/node_server/swagger_api/controllers/api_login_controller.js b/node_server/swagger_api/controllers/api_login_controller.js new file mode 100644 index 0000000..8ee0e16 --- /dev/null +++ b/node_server/swagger_api/controllers/api_login_controller.js @@ -0,0 +1,1204 @@ +/* eslint-disable id-match */ +/** + * Controller to manage the login functions + */ +'use strict'; +const Q = require('q'); +const httpStatus = require('http-status-codes'); +const mongodb = require('mongodb'); +const debug = require('debug')('webconsole-api:controllers:login'); +const promClient = require('prom-client'); +const apiSecurity = require('../api_security.js'); +const apiUtils = require('../api_utils.js'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const config = require(global.pathPrefix + 'config.js'); +const promiseUtil = require(global.pathPrefix + '../utils/promises.js'); +const utils = require(global.pathPrefix + 'utils.js'); +const serverVersion = require(global.pathPrefix + 'config.js').CCServerVersion; +const credentialsUtil = require(global.pathPrefix + '../utils/credentials.js'); +const hashUtil = require(global.pathPrefix + '../utils/hashing.js'); + +const counters = { + login: new promClient.Counter({ + name: 'bridge_server_consoleapi_logins_total', + help: 'Count of logins to webconsole api' + }), + elevate: new promClient.Counter({ + name: 'bridge_server_consoleapi_elevates_total', + help: 'Count of login elevations in webconsole api' + }) +}; + +module.exports = { + login, + poll2FA, + logout, + elevate, + demote, + acceptEULA, + keepAlive +}; + +/** + * Reasons that the client found by login may not be valid + */ +const REJECT_REASON_NOTFOUND = 0; +const REJECT_REASON_BARRED = 1; +const REJECT_REASON_TOO_MANY_FAILED_ATTEMPTS = 2; +const REJECT_REASON_SERVER_ERROR = 3; + +/** + * Text for BridgeLogin table + */ +const PSEUDO_DEVICE_NAME = 'Web'; +const OPERATION_TYPE_AWAIT_TWOFA = 'Await2FA'; +const OPERATION_TYPE_LOGIN = 'Login'; +const OPERATION_TYPE_LOGOUT = 'Logout'; +const OPERATION_TYPE_ELEVATE = 'Elevate'; +const OPERATION_TYPE_DEMOTE = 'Demote'; + +/** + * Function to check the login credentials. If the credentials are correct + * we return 2 items: + * - [cookie] X-BRIDGE-SESSION: our bridge session cookie + * - [body] X-XSRF-TOKEN: a XSRF blocking token that should be reflected back + * in a header by the JS (or other clients) in future + * requests. + * + * If the credentials are not correct we return a 401 Unauthorized response + * + * Note: The controller is called after the validator middleware so we don't + * need to validate the format of the parameters. + * + * @param {Object} req - Express request object, with additional information + * from Swagger. Particularly useful is `req.swagger` + * which contains information on this specific request. + * @param {Object} res - Express response object + */ +function login(req, res) { + debug('api/controllers/login called:'); + + const email = req.swagger.params.body.value.email; + const password = req.swagger.params.body.value.password; + + // + // Promise chain to manage login + // + validatePassword(email, password) + + // + // Initialise the session if user was found, report error if user not found + // + .then(initSession.bind(undefined, req, res), onClientError) + + // + // Do basic login immediately (as we don't need 2FA until elevation) + // + .then(doBasicLogin.bind(undefined, req, res)) + + // + // Check all the promises ran, and return any errors to the client + // + .catch((error) => { + loginError(req, res, error); + }); +} + +/** + * This function handles the decisions regarding whether to start 2FA or + * allow the process to continue immediately. + * 2FA protects the elevation of the session so, the immediate continuation is + * an elevated session. + * + * @param {Object} req - The request object + * @param {Object} res - The response object + * @param {string} clientID - The client ID of the current user + * @param {Object} basicResponse - The basic session information + * + * @returns {Promise} - A promise for the success of the function + */ +function checkAndStartTwoFA(req, res, clientID, basicResponse) { + debug('Checking for devices'); + + // + // Check if the user has any (active) devices. There are 3 cases: + // 1. Have active devices + // => Require 2FA to continue (for security) + // 2. Don't have any devices at all on the system (usually initially) + // => Don't require 2FA (as they have no devices to do 2fa with) + // 3. Have devices, but all devices are suspended + // => require contacting Comcarde. This is to prevent suspending of + // devices to allow unprotected access. + // + const query = { + ClientID: clientID + }; + const projection = { + _id: 0, + DeviceStatus: 1 // Only need device status + }; + + const deferred = Q.defer(); + const checkForDevices = deferred.promise; + mainDB.collectionDevice.find(query, projection) + .toArray((err, items) => { + if (err) { + return deferred.reject(err); + } else { + const hasDevices = items.length > 0; + let hasActiveDevice = false; + for (let i = 0; i < items.length; ++i) { + const status = items[i].DeviceStatus; + if ( + utils.bitsAllSet(status, utils.DeviceFullyRegistered) && + !utils.bitsAllSet(status, utils.DeviceSuspendedMask) && + !utils.bitsAllSet(status, utils.DeviceBarredMask) + ) { + hasActiveDevice = true; + break; + } + } + + return deferred.resolve({ + hasDevices, + hasActiveDevice, + basicResponse + }); + } + }); + + /** + * Handle the processing of whether we should proceed to the 2FA flow or not + */ + const handle2FA = checkForDevices.then((devicesInfo) => { + debug('Checked Devices: ', devicesInfo.hasDevices, devicesInfo.hasActiveDevice); + let handlerP = null; + if (devicesInfo.hasDevices === false) { + // No devices, so we allow login + handlerP = doElevatedLogin(req, res, devicesInfo.basicResponse); + } else if (devicesInfo.hasActiveDevice === false) { + // Have devices, but they are all suspended/barred so can't allow login + handlerP = doCant2FA(req, res); + } else { + // We have devices and can procced with waiting for 2FA responses + handlerP = doWaitFor2FA(req, res, devicesInfo.basicResponse); + } + + return handlerP; + }); + + return handle2FA; +} + +/** + * Finishes the basic login chain. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object + * @param {Object} basicResponse - The response info for returning + * + * @returns {Promise} - Promise that resolvse when we complete + */ +function doBasicLogin(req, res, basicResponse) { + debug('Doing basic login'); + return logLoginAndRespond(req, res, basicResponse) + .catch(loginError.bind(undefined, req, res)); +} + +/** + * Finishes the elevation login chain. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object + * @param {Object} basicResponse - The response info for returning + * + * @returns {Promise} - Promise that resolvse when we complete + */ +function doElevatedLogin(req, res, basicResponse) { + debug('Doing elevated login'); + return elevateAndRespond(req, res, basicResponse) + .catch(loginError.bind(undefined, req, res)); +} + +/** + * Returns a error status stating that 2FA is not possible because we don't + * have any active devices + * + * @returns {Promise} - Promise that resolvse when we complete + */ +function doCant2FA() { + debug('doing Cant2FA - has devices but none active'); + return promiseUtil.returnChainedError( + null, + httpStatus.CONFLICT, + 30011, + 'Active device required for 2-factor authentication.' + ); +} + +/** + * Lets the user know that they must wait for a 2FA authorisation from one of + * their devices. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object + * @param {Object} basicResponse - The response info for returning + * + * @returns {Promise} - Promise that resolvse when we complete + */ +function doWaitFor2FA(req, res, basicResponse) { + debug('doing WaitFor2FA - has active devices'); + + // + // Need to add a new 2FA request to the 2FA requests table + // + const timestamp = new Date(); + const expiry = new Date(timestamp); + expiry.setSeconds( + timestamp.getSeconds() + utils.twoFactorRequestExpiry + ); + const request = { + RequestID: utils.randomCode(utils.lowerCaseHex, utils.twoFactorTokenLength), + TargetAccount: req.session.data.clientID, + RequestDate: timestamp, + RequestExpiry: expiry, + RequesterDisplayName: req.session.data.displayName, + RequesterClientID: req.session.data.clientID, + AuthorisedDate: null, + LastUpdate: timestamp + }; + const addP = Q.nfcall(mainDB.addObject, mainDB.collectionTwoFARequests, request, undefined, false); + + return addP.then(() => { + req.session.data.twoFARequestID = request.RequestID; + const awaitResponse = { + 'X-XSRF-TOKEN': basicResponse['X-XSRF-TOKEN'] + }; + return logAwait2FAAndRespond(req, res, awaitResponse) + .catch(loginError.bind(undefined, req, res)); + }).catch(() => { + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30401, + info: 'Database unavailable' + }); + }); +} + +/** + * Function to check if the 2FA request that we are waiting for has been + * authorised (or has timed out). + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object + */ +function poll2FA(req, res) { + debug('api/controllers/poll2FA called:'); + const clientID = req.session.data.clientID; + const requestID = req.session.data.twoFARequestID; + + // + // Set up the query. Note that we don't exclude items with + // AuthoriseDate = null at this point because we want to check that later. + // + const timestamp = new Date(); + const query = { + RequestID: requestID, + TargetAccount: clientID, + RequestExpiry: {$gt: timestamp} + }; + const projection = { + _id: 0, + AuthorisedDate: 1 + }; + const options = { + fields: projection, + comment: 'WebConsole:poll2FA' // For profiler logs use + }; + + // + // Check for the status of the 2FA request + // + const TIMED_OUT = 'Bridge: 2FA timed out or invalid'; + const STILL_WAITING = 'Bridge: Still waiting for 2FA'; + + const checkP = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionTwoFARequests, + query, + options, + false + ).then((result) => { + if (result === null) { + // + // Didn't find anything that matches. Must be invalid, or + // have expired + // + return Q.reject(TIMED_OUT); + } else if (result.AuthorisedDate === null) { + // Not authorised yet + return Q.reject(STILL_WAITING); + } else { + return Q.resolve(); + } + }); + + // + // If the 2FA has been authorised we need to get the client info, then + // continue with rest of the elevated login. + // + const CLIENT_NOT_FOUND = 'Bridge: Client not found'; + const getClientP = checkP.then(() => { + const clientQuery = { + _id: mongodb.ObjectID(req.session.data.client) + }; + const clientOptions = { + comment: 'WebConsole:poll2FA' + }; + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + clientQuery, + clientOptions, + false + ).then((result) => { + if (result) { + return Q.resolve(result); + } else { + return Q.reject(CLIENT_NOT_FOUND); + } + }); + }); + + // + // Run the rest of the elevation process now that we have 2FA and a client + // + const doLoginP = getClientP.then((client) => { + return initSession(req, res, client).then((basicResponse) => { + return doElevatedLogin(req, res, basicResponse); + }, onSessionError); + }); + + Q.all([checkP, getClientP, doLoginP]).catch((error) => { + debug('poll2FA catch: ', error); + let name = error; + if (error.hasOwnProperty('name')) { + name = error.name; + } + + switch (name) { + case STILL_WAITING: + // Still waiting for authorisation + res.status(httpStatus.ACCEPTED).json(); + break; + case TIMED_OUT: + // Timed out (or invalid), and will never work + res.status(httpStatus.REQUEST_TIMEOUT).json(); + break; + case CLIENT_NOT_FOUND: + // Client somehow got deleted or suspended in the gap + res.status(httpStatus.UNAUTHORIZED).json({ + code: 30402, + info: 'Client not found' + }); + break; + case 'MongoError': + // Mongo Error + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30403, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + }); +} + +/** + * Function to elevate the session. In most cases, this triggers a request + * for 2nd factor authentication via a registered mobile app. + * + * @see checkAndStartTwoFA() + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object + */ +function elevate(req, res) { + debug('api/controllers/elevate called:'); + + // Get the email from the session before we reset it. + const clientID = req.session.data.clientID; + + // + // Promise chain to manage login + // + return getAndVerifyClient(req, res) + + // + // Initialise the session if user was found, report error if user not found + // + .then(initSession.bind(undefined, req, res), onClientError) + + // + // Log the successful login and send the response + // + .then(checkAndStartTwoFA.bind(undefined, req, res, clientID), onSessionError) + + // + // Send any errors back to the client + // + .catch(loginError.bind(undefined, req, res)) + + // + // And end the chain here (catching any unexpected errors) + // + .done(); +} + +/** + * Function to demote the session back down to the standard value. We then + * reset the session tokens (for security). + * + * This works in a very similar way to the elevate function, except we don't + * have any credentials to verify (demotion being less security critical than + * elevation. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object + */ +function demote(req, res) { + debug('api/controllers/demote called:'); + + // + // Promise queue to manage the demotion + // + return getAndVerifyClient(req, res) + + // + // Initialise the session if user was found, report error if user not found + // + .then(initSession.bind(undefined, req, res), onClientError) + + // + // Log the successful demotion and send the response + // + .then(demoteAndRespond.bind(undefined, req, res), onSessionError) + + // + // Send any errors back to the client + // + .catch(loginErrorKeepSession.bind(undefined, req, res)) + + // + // And end the chain here (catching any unexpected errors) + // + .done(); +} + +/** + * Gets the client object based on the id stored in the session. + * It then verifies that the client is valid and active, hasn't been banned, etc + * + * @param {Object} req - The request object + * + * @returns {Promise} - Resolves to the appropriate client object + */ +function getAndVerifyClient(req) { + const id = req.session.data.client; + + // + // Search for a matching client object + // + const deferred = Q.defer(); + const promise = deferred.promise; + + mainDB.findOneObject( + mainDB.collectionClient, + {_id: mongodb.ObjectId(id)}, + undefined, + false, + (err, result) => { + if (err) { + debug('- failed to find Client', id, err); + deferred.reject(err); + } else { + debug('- found Client', id); + + // + // We ignore the password match because we are only looking + // for this user, not trying to verify them (as they have + // a session). + // + const response = { + client: result, + dontVerifyPassword: true + }; + deferred.resolve(response); + } + } + ); + + // + // Promise queue to manage the demotion + // + const verifiedClientP = promise + + // + // Check if we found a matching user. Note: there are two error cases: + // 1) DB not accessible - this is handled if the error function + // 2) User not found in db - this goes to the success case, with a null response + // + .then(onQueryComplete, onDbServerError); + + return verifiedClientP; +} + +/** + * Function to logout. + * This is simply destroying the session so it can't be used again. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function logout(req, res) { + debug('api/controllers/logout called:'); + + // + // Need to get the details before we destroy the session + // + const logoutDetails = getLoginLogValues(req, OPERATION_TYPE_LOGOUT); + + // + // We also need to remove any pending 2FA requests on logout as they are + // no longer valid. We don't wait to see if it succeeds, as there's nothing + // we would do differently if it fails + // + const query = { + TargetAccount: req.session.data.clientID + }; + Q.ninvoke( + mainDB.collectionTwoFARequests, + 'deleteMany', + query + ); + + // + // Now destroy the session + // + req.session.destroy((err) => { + if (err) { + const errorResponse = new promiseUtil.ErrorResponse( + httpStatus.INTERNAL_SERVER_ERROR, + 214, + 'Failed to destroy session' + ); + err[promiseUtil.ERR_KEY] = errorResponse; + promiseUtil.sendErrorResponse(res, err); + } else { + // + // Session destroyed, so log that logout is now done + // + mainDB.addObject( + mainDB.collectionBridgeLogin, + logoutDetails, + undefined, + false, + (addErr) => { + if (addErr) { + debug('Failed to log logout details', addErr); + const errorResponse = new promiseUtil.ErrorResponse( + httpStatus.INTERNAL_SERVER_ERROR, + 225, + 'Failed to log logout details' + ); + addErr[promiseUtil.ERR_KEY] = errorResponse; + promiseUtil.sendErrorResponse(res, addErr); + } else { + // Logout worked, so pass the response on for return + res.status(200).json(); + debug('- logout SUCCEEDED'); + } + }); + } + }); +} + +/** + * Function to accept the specified version of the EULA + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function acceptEULA(req, res) { + const id = req.session.data.client; + + const eulaVersion = req.swagger.params.body.value.acceptedVersion; + + if (eulaVersion === config.EULAVersion) { + const query = { + _id: mongodb.ObjectId(id) + }; + const update = { + $set: { + EULAVersionAccepted: eulaVersion + }, + $currentDate: { + LastUpdate: true + }, + $inc: { + LastVersion: 1 + } + }; + const options = { + upsert: false, + returnOriginal: false // want the updated document + }; + + Q.ninvoke( + mainDB.collectionClient, + 'findOneAndUpdate', + query, + update, + options + ) + + // + // Initialise the session if user was found, report error if user not found + // + .then((response) => initSession(req, res, response.value), onClientError) + + // + // Do basic login immediately (as we don't need 2FA until elevation) + // + .then(doBasicLogin.bind(undefined, req, res)) + + // + // Check all the promises ran, and return any errors to the client + // + .catch((error) => { + loginError(req, res, error); + }); + } else { + res.status(httpStatus.BAD_REQUEST).json({ + code: 291, + info: 'Wrong EULA version' + }); + } +} + +/** + * Function to just keep the session alive. Doesn't do anything except refresh + * the session (which we have to do manually so that we can pick up the new date + * to report back to the client). + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function keepAlive(req, res) { + // Re-save the session to force the expiry to be updated + req.session.save((err) => { + if (!err) { + // Re-load the session to pick up the new expires date + req.session.reload(() => { + res.status(httpStatus.OK).json(); + }); + } + }); +} + +/** + * Function to validate the email and password of the client. Returns a promise + * ther resolves on success to the client object, and rejects on failure with + * an error code. See utils/hasher.js and utils/credentials.js for possible + * error codes. + * + * @param {string} email - the email address + * @param {string} password - the password + * + * @returns {promise} - resolves when the password is validated + */ +function validatePassword(email, password) { + /* Ignore warning about cylcomatic complexity */ + /* eslint-disable complexity */ + return credentialsUtil.validateRawPassword(email, password) + .then((result) => { + debug('= Validated: '); + return result; + }) + .catch((error) => { + debug('- Failed to validate: ', error); + + // + // Convert the error reason to the more limited set in use here + // + /* eslint-disable lines-around-comment */ + switch (error) { + // + // Actually not found, and password doesn't match + // are both called "No Match" to make it less obvious to + // an attacker + // + case credentialsUtil.ERRORS.NOT_FOUND: + case hashUtil.ERRORS.NO_MATCH: + debug('NO_MATCH'); + return Q.reject(REJECT_REASON_NOTFOUND); + + case credentialsUtil.ERRORS.BARRED: + return Q.reject(REJECT_REASON_BARRED); + + case credentialsUtil.ERRORS.TOO_MANY_ATTEMPTS: + return Q.reject(REJECT_REASON_TOO_MANY_FAILED_ATTEMPTS); + + // + // A number of different cases come down to server error + // + case credentialsUtil.ERRORS.CANT_UPDATE_ATTEMPTS_SUCCESS: + case credentialsUtil.ERRORS.CANT_UPDATE_ATTEMPTS_FAIL: + case credentialsUtil.ERRORS.CANT_SEND_WARNING_EMAIL: + case hashUtil.ERRORS.UNKNOWN_ALGO: + case hashUtil.ERRORS.HASH_FAILED: + case hashUtil.ERRORS.SALT_FAILED: + return Q.reject(REJECT_REASON_SERVER_ERROR); + + default: + // Also a server error + return Q.reject(REJECT_REASON_SERVER_ERROR); + } + }); +} + +/** + * Checks the result of the query. Failure reasons can be: + * - client === NULL => no matches (i.e. wrong user name) + * - client.ClientStatus is barred => client is barred and can't login + * - client.LoginAttempts too high => too many failed login attempts, not allowed to connect + * - client password doesn't match => counts as no matches + * + * @param {Object} result - the response from the server + the expected PW + * + * @returns {Promise} - promise that rejects or resolves as appropriate + */ +function onQueryComplete(result) { + const defer = Q.defer(); + const client = result.client; + + // + // Check if we should verify the password. By default we do, but we can + // skip it if we are coming from somewhere like demote (where we don't ask + // for the password again). + let verifyPassword = true; + if (result.hasOwnProperty('dontVerifyPassword')) { + verifyPassword = !result.dontVerifyPassword; + } + + if (!client) { + defer.reject(REJECT_REASON_NOTFOUND); + } else if (client.ClientStatus & utils.ClientBarredMask) { + defer.reject(REJECT_REASON_BARRED); + } else if (client.LoginAttempts > utils.passwordLockout) { + defer.reject(REJECT_REASON_TOO_MANY_FAILED_ATTEMPTS); + } else if (verifyPassword && client.Password !== result.expectedPW) { + // + // Password wrong. + // We return an identical result to client not found at all so as not + // to leak any information on what part was wrong. + // We also "fire and forget" an update of the failed login count. + // It's fire and forget because: + // 1. we won't change our user response if it fails, and + // 2. we don't want a timing difference between didn't find user and + // password was wrong (which waiting for a response would give). + // + const attemptQuery = { + ClientName: client.ClientName + }; + const attemptUpdate = { + $inc: {LoginAttempts: 1} + }; + mainDB.updateObject( + mainDB.collectionClient, + attemptQuery, + attemptUpdate, + undefined, + false + ); + + defer.reject(REJECT_REASON_NOTFOUND); + } else { + // + // Successful login, so reset the failed attempts count to 0. + // This is "fire and forget" because we wouldn't change our result + // even if we can't update the value. + // + const successQuery = { + ClientName: client.ClientName + }; + const successUpdate = { + $set: {LoginAttempts: 0} + }; + mainDB.updateObject( + mainDB.collectionClient, + successQuery, + successUpdate, + undefined, + false + ); + + defer.resolve(client); + } + + return defer.promise; +} + +/** + * Initialises the session for the specified client + * + * @param {Object} req - the express request object + * @param {Object} res - the express response object + * @param {Object} client - the client object to build the session for + * + * @returns {Promise} - a promise for the completion of this function + */ +function initSession(req, res, client) { + debug(' - found client', client.ClientName); + + return apiUtils.initSession(req, client).catch(() => { + return Q.reject( + promiseUtil.ErrorResponse( + httpStatus.INTERNAL_SERVER_ERROR, + 41, + 'Failed to initialise session' + )); + }); +} + +/** + * Log the fact that we are awaiting 2FA authorisation + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Object} response - The response to eventually be sent to the customer + * + * @returns {Promise} - Promise for the result of the function + */ +function logAwait2FAAndRespond(req, res, response) { + // + // Mark the session as basic + // + req.session.data.level = apiSecurity.SESSION_TYPES.AWAITING_2FA; + + const defer = Q.defer(); + const loginLog = getLoginLogValues(req, OPERATION_TYPE_AWAIT_TWOFA); + mainDB.addObject(mainDB.collectionBridgeLogin, loginLog, undefined, false, (err) => { + if (err) { + debug('Failed to log login details', err); + defer.reject(onDbServerError(err)); + } else { + // Login worked, so pass the response on for return + res.status(httpStatus.ACCEPTED).json(response); + debug('- login SUCCEEDED'); + defer.resolve(); + } + }); + + return defer.promise; +} + +/** + * Log the Login details + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Object} response - The response to eventually be sent to the customer + * + * @returns {Promise} - Promise for the result of the function + */ +function logLoginAndRespond(req, res, response) { + // + // Mark the session as basic, unless we need to confirm the EULA. + // + if (response.newEULA) { + req.session.data.level = apiSecurity.SESSION_TYPES.AWAITING_ACCEPT_EULA; + } else { + req.session.data.level = apiSecurity.SESSION_TYPES.BASIC; + } + + const defer = Q.defer(); + const loginLog = getLoginLogValues(req, OPERATION_TYPE_LOGIN); + mainDB.addObject(mainDB.collectionBridgeLogin, loginLog, undefined, false, (err) => { + if (err) { + debug('Failed to log login details', err); + defer.reject(onDbServerError(err)); + } else { + counters.login.inc(); + // Login worked, so pass the response on for return + res.status(200).json(response); + debug('- login SUCCEEDED'); + defer.resolve(); + } + }); + + return defer.promise; +} + +/** + * Mark the session as elevated, then log the completion and send the result + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Object} response - The response to eventually be sent to the customer + * + * @returns {Promise} - Promise for the result of the function +*/ +function elevateAndRespond(req, res, response) { + // + // Mark the session as elevated + // + req.session.data.level = apiSecurity.SESSION_TYPES.ELEVATED; + + const defer = Q.defer(); + const loginLog = getLoginLogValues(req, OPERATION_TYPE_ELEVATE); + mainDB.addObject(mainDB.collectionBridgeLogin, loginLog, undefined, false, (err) => { + if (err) { + debug('Failed to log elevation details', err); + defer.reject(onDbServerError(err)); + } else { + counters.elevate.inc(); + // Login worked, so pass the response on for return + res.status(200).json(response); + debug('- elevate SUCCEEDED'); + defer.resolve(); + } + }); + + return defer.promise; +} + +/** + * Mark the session as demoted, then log the completion and send the result + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Object} response - The response to eventually be sent to the customer + * + * @returns {Promise} - Promise for the result of the function + */ +function demoteAndRespond(req, res, response) { + // + // Note: we don't have to do anything special as a re-initialised sesion + // is automatically at the basic level + // + const defer = Q.defer(); + const loginLog = getLoginLogValues(req, OPERATION_TYPE_DEMOTE); + mainDB.addObject(mainDB.collectionBridgeLogin, loginLog, undefined, false, (err) => { + if (err) { + debug('Failed to log demotion details', err); + defer.reject(onDbServerError(err)); + } else { + // Login worked, so pass the response on for return + res.status(200).json(response); + debug('- demote SUCCEEDED'); + defer.resolve(); + } + }); + + return defer.promise; +} + +/** + * Get the values to be used for the login/logout table + * + * @param {Object} req - Express request object + * @param {string} operation - Operation type (login or logout) + * + * @returns {Object} - The object to be pushed to the database + */ +function getLoginLogValues(req, operation) { + return { + ClientID: req.session.data.clientID, + DeviceToken: PSEUDO_DEVICE_NAME, + SessionToken: req.session.id, + OperationType: operation, + SourceIP: req.ip, + DateTime: new Date(), + APIVersion: req.swagger.swaggerObject.info.version, + ServerVersion: serverVersion, + DeviceSoftware: req.headers['user-agent'] + }; +} + +/** + * Handles errors in connecting to the database. + * + * @param {Object} err - Error object. + * + * @returns {Promise} - Rejected promise with appropriate error details + */ +function onDbServerError(err) { + debug('- database error: ', err); + + return promiseUtil.returnChainedError( + err, + httpStatus.BAD_GATEWAY, + 45, + 'Database offline' + ); +} + +/** + * Handles error where we failed to create the session + * + * @param {Object} err - Error object + * + * @returns {Promise} - Rejected promise with appropriate error details + */ +function onSessionError(err) { + if (promiseUtil.hasChainedError(err)) { + return promiseUtil.resendChainedError(err); + } else { + debug(' - failed to create session: ', err); + + // + // We don't know if it was the email or password that failed, so + // we can only return a generic failure + // + return promiseUtil.returnChainedError( + err, + httpStatus.BAD_GATEWAY, + 49, + 'Login failed.' + ); + } +} + +/** + * Handles error where: + * a) no user is found in the database with the provided password. + * b) a user is found, but they are barred. + * + * @param {Object} err - Error object + * + * @returns {Promise} - Rejected promise with appropriate error details + */ +function onClientError(err) { + if (promiseUtil.hasChainedError(err)) { + return promiseUtil.resendChainedError(err); + } else if (err === REJECT_REASON_NOTFOUND) { + debug(' - user not found: ', err); + + // + // We don't know if it was the email or password that failed, so + // we can only return a generic failure + // + return promiseUtil.returnChainedError( + err, + httpStatus.UNAUTHORIZED, + 132, + 'Login failed.' + ); + } else if (err === REJECT_REASON_BARRED) { + debug(' - user BARRED'); + + // + // This user is barred, so HTTP status is FORBIDDEN (retrying + // authentication won't help) + // + return promiseUtil.returnChainedError( + err, + httpStatus.FORBIDDEN, + 100, + 'Client barred.' + ); + } else if (err === REJECT_REASON_TOO_MANY_FAILED_ATTEMPTS) { + debug(' - TOO MANY FAILED ATTEMPTS'); + + // + // The user has had too many failed login attempts, and can't login. + // so HTTP status is FORBIDDEN (retrying authentication won't help) + // + return promiseUtil.returnChainedError( + err, + httpStatus.FORBIDDEN, + 201, + 'Too many failed attempts.' + ); + } else if (err === REJECT_REASON_SERVER_ERROR) { + debug(' - server error'); + return promiseUtil.returnChainedError( + err, + httpStatus.INTERNAL_SERVER_ERROR, + 41, + 'Server error' + ); + } else { + // Should never get here as the above entries should cover all the options + debug(' - unknown server error'); + return promiseUtil.returnChainedError( + err, + httpStatus.INTERNAL_SERVER_ERROR, + 999, + 'Unspecified Server error' + ); + } +} + +/** + * Final handler for all login error cases. In this handler we delete the session + * so that you can't accidentally use an old session if you failed to login + * with different credentials + * + * @param {Object} req - express request object + * @param {Object} res - express response object; + * @param {Object} err - the cascaded error object + */ +function loginError(req, res, err) { + // + // Destroy the session. + // + req.session.destroy((destroyErr) => { + if (destroyErr) { + // + // We are already going to return an error, so not much we can do + // here except log it. + // + debug('-failed to destroy session from failed login.', destroyErr); + } + }); + + promiseUtil.sendErrorResponse(res, err); +} + +/** + * Final handler for all elevate/demote error cases. In this handler we DON'T + * delete the session unless the user is now barred or has failed too many + * times. In those cases the session should be destroyed so they have no + * further access to even the standard APIs. + * + * @param {Object} req - express request object + * @param {Object} res - express response object; + * @param {Object} err - the cascaded error object + */ +function loginErrorKeepSession(req, res, err) { + // + // We know it's a bad error if the code is 403 Forbidden. + // + const response = promiseUtil.getChainedError(err); + if (response && response.httpcode === httpStatus.FORBIDDEN) { + if (req.session) { + req.session.destroy(); + } + } + + // + // Send the error + // + promiseUtil.sendErrorResponse(res, err); +} diff --git a/node_server/swagger_api/controllers/api_merchant_controller.js b/node_server/swagger_api/controllers/api_merchant_controller.js new file mode 100644 index 0000000..925e9a9 --- /dev/null +++ b/node_server/swagger_api/controllers/api_merchant_controller.js @@ -0,0 +1,98 @@ +/** + * Controller to manage the merchant status + */ +'use strict'; + +var _ = require('lodash'); +var Q = require('q'); +var httpStatus = require('http-status-codes'); +var mongodb = require('mongodb'); +var debug = require('debug')('webconsole-api:controllers:merchant'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); + +module.exports = { + addMerchantPromoCode: addMerchantPromoCode +}; + +/** + * Enable the merchant status based on a valid promo code + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function addMerchantPromoCode(req, res) { + // + // Check that the client is a merchant + // + if (req.session.data.isMerchant) { + res.status(httpStatus.CONFLICT).json({ + code: 31001, + info: 'Already a merchant' + }); + return; + } + + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var promoCode = req.swagger.params.body.value.PromoCode; + + // + // Check the promo code is the one we have hardcoded + // + const DEFAULT_PROMO_CODE = 'c4e2cd44f7774ad5847ca6d5'; + if (promoCode !== DEFAULT_PROMO_CODE) { + res.status(httpStatus.BAD_REQUEST).json({ + code: 31002, + info: 'Invalid Promotion Code' + }); + return; + } + + // + // Define the query according to the params + // + var query = { + ClientID: clientID + }; + var update = { + $set: { + 'Merchant.0.MerchantStatus': 1 + }, + $currentDate: { + LastUpdate: true + }, + $inc: { + LastVersion: 1 + } + }; + + var options = { + upsert: false // Don't upsert if not found + }; + + mainDB.updateObject(mainDB.collectionClient, query, update, options, false, + function(err, results) { + if (err) { + debug('- failed to update Merchant', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 31003, + info: 'Database offline' + }); + } else if (results.result.n === 0) { + // + // Nothing found - perhaps the client has been removed in the interim? + // + res.status(httpStatus.NOT_FOUND).json({ + code: 31004, + info: 'Client not found' + }); + } else { + // All good - update the session to note that we are now a merchant + req.session.data.isMerchant = true; + res.status(httpStatus.OK).json(); + } + }); + +} diff --git a/node_server/swagger_api/controllers/api_postcodes_controller.js b/node_server/swagger_api/controllers/api_postcodes_controller.js new file mode 100644 index 0000000..ecba37c --- /dev/null +++ b/node_server/swagger_api/controllers/api_postcodes_controller.js @@ -0,0 +1,37 @@ +/** + * Controller to manage the Content Security Policy reporting functions + */ +'use strict'; + +var httpStatus = require('http-status-codes'); +var debug = require('debug')('webconsole-api:controllers:postcodes'); +var postcodeUtils = require(global.pathPrefix + '../utils/postcodes.js'); +var responseUtils = require(global.pathPrefix + '../utils/responses.js'); +var swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); + +module.exports = { + postcodeLookup: postcodeLookup +}; + +/** + * Runs a postcode lookup and returns a list of addresses that could match that + * postcode. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function postcodeLookup(req, res) { + debug('Postcode Lookup: ', req.swagger.params.postcode.originalValue); + + const lookupP = postcodeUtils.postcodeLookup(req.swagger.params.postcode.originalValue); + lookupP.then((addresses) => { + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, addresses); + res.status(httpStatus.OK).json(addresses); + }).catch((error) => { + const responseHandler = new responseUtils.ErrorResponses([]); + responseHandler.respond(res, error); + }); +} diff --git a/node_server/swagger_api/controllers/api_recovery_controller.js b/node_server/swagger_api/controllers/api_recovery_controller.js new file mode 100644 index 0000000..85567d4 --- /dev/null +++ b/node_server/swagger_api/controllers/api_recovery_controller.js @@ -0,0 +1,1087 @@ +/** + * Controller to manage the account recovery functions + */ +'use strict'; + +const httpStatus = require('http-status-codes'); +const debug = require('debug')('webconsole-api:controllers:recovery'); +const _ = require('lodash'); +const Q = require('q'); +const moment = require('moment'); + +const utils = require(global.pathPrefix + 'utils.js'); +const mailer = require(global.pathPrefix + 'mailer.js'); +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const sms = require(global.pathPrefix + 'sms.js'); +const references = require(global.pathPrefix + '../utils/references.js'); +const responseUtils = require(global.pathPrefix + '../utils/responses.js'); +const templates = require(global.pathPrefix + '../utils/templates.js'); +const clientUtils = require(global.pathPrefix + '../utils/client/client.js'); +const apiUtils = require(global.pathPrefix + '../swagger_api/api_utils.js'); + +/** + * Predefined errors from shared functions + */ +const NO_MATCH = 'BRIDGE: Invalid token'; +const NO_RETRIES = 'BRIDGE: Too many retries'; +const WRONG_STATE = 'BRIDGE: Wrong state'; +const HASH_FAILED = 'BRIDGE: Failed to hash password'; +const FAILED_UPDATE_DB = 'BRIDGE: Failed to store new password'; + +/** + * States in the recovery process state machine. + * We use these to ensure the correct request is being received at the correct + * time, so e.g. a client can't try to complete password reset with just an email + * token when they should also be confirming a device. + */ +const STATES = { + START: 0, + WAITING_FOR_EMAIL_TOKEN_PASSWORD: 1, + WAITING_FOR_EMAIL_TOKEN: 2, + WAITING_FOR_ANSWERS: 3, + WAITING_FOR_SMS_TOKEN_PASSWORD: 4 +}; + +/** + * Types of questions + */ +const QTYPE = { + POSTCODE: 'postcode', + CARD: 'card', + TRANSACTION: 'transactions', + DEVICE: 'device', + DOB: 'dob' +}; + +/** + * Exports from this module + */ +module.exports = { + startRecovery, + completeRecoveryEmailPw, + confirmRecoveryEmail, + confirmAnswers, + completeRecoveryDevicePw +}; + +/** + * Sets the next expected state and any associated data for that state. + * This is stored in the session and used to verify that we are in the appropriate state. + * We also initialise the number of retries we are allowed for this next state. + * + * @param {Object} req - The request object (which holds the session info). + * @param {number} state - The next state. MUST be a member of STATES. + * @param {any} data - The data for the next state. + */ +function setNextState(req, state, data) { + debug( + 'STATE TRANSITION: ', + _.get(req, 'session.data.nextState', 'n/a'), '=>', state + ); + _.set(req, 'session.data.nextState', state); + _.set(req, 'session.data.stateData', data); + _.set(req, 'session.data.stateRetries', utils.recoveryRetries); +} + +/** + * Gets the data for the expected state after verifying that this is the state + * we should be in, and that we still have retries left. + * + * @param {Object} req - The request object (which holds the session data). + * @param {number} expectedState - The state we expect to be in. MUST be from STATES. + * @returns {Promise} - The data for this state. + */ +function getStateData(req, expectedState) { + debug('STATE START: ', expectedState, '[expecting: ', req.session.data.nextState, ']'); + + /** + * Check we are in the right state + */ + if (expectedState !== req.session.data.nextState) { + return Q.reject(WRONG_STATE); + } + + /** + * Check we still have retries left (and reduce the count if we do) + */ + if (req.session.data.stateRetries <= 0) { + // Destroy session so it can no longer be used + req.session.destroy(); + + return Q.reject(NO_RETRIES); + } else { + req.session.data.stateRetries -= 1; + } + + /** + * All good so return the data + */ + return Q.resolve(req.session.data.stateData); +} + +/** + * Shared function to update the password in the database. + * + * @param {string} clientID - The ID of the client to update. + * @param {string} newPassword - The new password for that client. + * + * @returns {Promise} - Result of updating the password. + */ +function updatePassword(clientID, newPassword) { + /** + * Step 1. Hash the password + */ + const hashP = apiUtils.encodePassword(newPassword) + .catch((error) => { + debug('Failed to hash password', error); + + return Q.reject(HASH_FAILED); + }); + + /** + * Step 2. Update the account + */ + const updateP = hashP.then((hashed) => { + const hashedPassword = hashed.hash; + const salt = hashed.salt; + + const query = { + ClientID: clientID + }; + + const update = { + $set: { + Password: hashedPassword, + ClientSalt: salt, + 'PasswordManagement.0.RecoveryLimits.Attempts': 0 // Complete recovery, so reset + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true, + 'PasswordManagement.0.RecoveryLimits.AllowAfter': true // Complete recovery, so reset + } + }; + + const options = { + upsert: false + }; + + return Q.nfcall( + mainDB.updateObject, + mainDB.collectionClient, + query, + update, + options, + false // Don't suppress errors - we expect this to succed + ).then((res) => { + if (res.result.nModified === 1) { + return Q.resolve(); + } else { + debug('DB ran, but didnt update 1 entry:', res.result.nModified); + + return Q.reject(FAILED_UPDATE_DB); + } + }).catch(() => Q.reject(FAILED_UPDATE_DB)); + }); + + return Q.all([hashP, updateP]); +} + +/** + * Gets a list of Knowledge-Based Authentication questions and answers for a client. + * + * @param {string} clientID - ID of the client in question. + * @returns {Promise} - Promise for the questions and answers. + */ +function getKba(clientID) { + // + // Knoweldge Based Authentication questions are generated by a number of + // different functions. They all have the same format, so we can iterate + // over them to get to the full list of questions to select from. + // + const kbaFuncs = [ + getKbaAddresses, + getKbaCards, + getKbaClientDetails, + getKbaDevices, + getKbaTransactions + ]; + const kbaPs = []; + + for (let i = 0; i < kbaFuncs.length; ++i) { + kbaPs.push(kbaFuncs[i](clientID)); + } + + // + // Wait for all the questions to be generated, then prepare the results + // + return Q.all(kbaPs).then((results) => { + // Merge all the sub results + const all = [].concat(...results); + + // + // Pick a random sample of them, then split out the questions and + // answers into seperate arrays (questions to send, answers to keep). + // + const kbas = _.sampleSize(all, utils.recoveryQuestionsCount); + const result = { + questions: _.map( + kbas, + (kba) => _.pick(kba, ['questionID', 'questionType', 'questionText']) + ), + answers: _.map( + kbas, + (kba) => _.pick(kba, ['questionID', 'questionType', 'answer']) + ) + }; + + return result; + }); +} + +/** + * Gets a set of KBA questions related to addresses. Specifically, the postcode + * of an address with the given description. + * + * @param {string} clientID - The client id. + * @returns {Promise} - Promise for the questions and answers. + */ +function getKbaAddresses(clientID) { + return mainDB.collectionAddresses + .find({ + ClientID: clientID + }) + .project({ + AddressDescription: 1, + PostCode: 1 + }) + .toArray() + .then((addresses) => { + const kbas = []; + + for (let i = 0; i < addresses.length; ++i) { + const id = utils.timeBasedRandomCode(); + + kbas.push({ + questionID: id, + questionType: QTYPE.POSTCODE, + questionText: addresses[i].AddressDescription, + answer: addresses[i].PostCode + }); + } + + return kbas; + }); +} + +/** + * Gets KBA questions based on credit/debit card details. We give them the + * name; they give us back the last 3 digits. + * + * @param {string} clientID - The client ID. + * @returns {Promise} - Array of question and answer objects. + */ +function getKbaCards(clientID) { + return mainDB.collectionAccount + .find({ + ClientID: clientID, + AccountType: 'Credit/Debit Payment Card', + $or: [ + {AccountStatus: 0}, + {AccountStatus: 1} + ] + }) + .project({ + ClientAccountName: 1, + CardPAN: 1 + }) + .toArray() + .then((accounts) => { + const kbas = []; + + for (let i = 0; i < accounts.length; ++i) { + const id = utils.timeBasedRandomCode(); + + // + // The card PAN can be anonymised in a number of ways depending + // on how many characters it has. In particular, the last 3 + // digits might have a space in the middle depending on how the + // groups of 4 end up. e.g. + // - ...***1 23 + // - ... **** 123 + // - ... *123 + // + // So we grab the last 4 digits, and remove the space/* wherever + // we find it. + // + const anonPan = accounts[i].CardPAN.slice(-4); // Last 4 characters + const last3 = anonPan.replace(/[ *]/, ''); // Remove any spaces or *s + + kbas.push({ + questionID: id, + questionType: QTYPE.CARD, + questionText: accounts[i].ClientAccountName, + answer: last3 + }); + } + + return kbas; + }); +} + +/** + * Get KBA questions based on recent transactions. + * At present we only ask how many transactions you have PAID in the past week + * with Bridge. + * + * @param {string} clientID - The client ID. + * @returns {Promise} - Array of question and answer objects. + */ +function getKbaTransactions(clientID) { + const since = moment().subtract(7, 'days').startOf('day').utc(); + + debug('Finding transactions since', since); + + return mainDB.collectionTransaction + .find({ + CustomerClientID: clientID, + TransactionStatus: utils.TransactionStatus.COMPLETE, + SaleTime: { + $gte: since.toDate() + } + }) + .project({ + _id: 1 + }) + .limit(5) // We only care for up to 5 transactions + .toArray() + .then((transactions) => { + const id = utils.timeBasedRandomCode(); + const kbas = [{ + questionID: id, + questionType: QTYPE.TRANSACTION, + questionText: since.toISOString(), + answer: transactions.length + }]; + + return kbas; + }); +} + +/** + * Gets KBA questions based on device details. We give them the + * name; they give us back the phone number. + * + * @param {string} clientID - The client ID. + * @returns {Promise} - Array of question and answer objects. + */ +function getKbaDevices(clientID) { + return mainDB.collectionDevice + .find({ + ClientID: clientID + }) + .project({ + DeviceName: 1, + DeviceNumber: 1 + }) + .toArray() + .then((devices) => { + const kbas = []; + + for (let i = 0; i < devices.length; ++i) { + const id = utils.timeBasedRandomCode(); + + kbas.push({ + questionID: id, + questionType: QTYPE.DEVICE, + questionText: devices[i].DeviceName, + answer: devices[i].DeviceNumber + }); + } + + return kbas; + }); +} + +/** + * Gets KBA questions based on personal details. i.e. what is their date of birth. + * + * @param {string} clientID - The client ID. + * @returns {Promise} - Array of question and answer objects. + */ +function getKbaClientDetails(clientID) { + return references.getClient(clientID) + .then((client) => { + const kbas = []; + const dob = _.get(client, 'KYC[0].DateOfBirth', ''); + const id = utils.timeBasedRandomCode(); + + if (dob !== '') { + kbas.push({ + questionID: id, + questionType: QTYPE.DOB, + questionText: '', + answer: dob + }); + } + + return kbas; + }); +} + +/** + * Verifies that the answers provided to the KBA questions are correct. + * + * @param {Object[]} responses - Array of the answers provided by the caller. + * @param {Object[]} expected - Array of expected answers. + * @returns {Promise} - Promise for the verification. + */ +function verifyAnswers(responses, expected) { + // + // Check we have the correct number of responses + // + if (responses.length !== expected.length) { + return Q.reject(NO_MATCH); + } + + // + // Sort the responses and expected answers by the ID to ensure they are + // in the same order + // + const sortedResponses = _.sortBy(responses, 'questionID'); + const sortedExpected = _.sortBy(expected, 'questionID'); + + // + // Now compare them in order where the IDs and answers should match. + // Each question type has a verifier that compares them in different ways. + // + const verifierLookup = {}; + + verifierLookup[QTYPE.POSTCODE] = verifyKbaPostcode; + verifierLookup[QTYPE.CARD] = verifyKbaCard; + verifierLookup[QTYPE.TRANSACTION] = verifyKbaTransactions; + verifierLookup[QTYPE.DEVICE] = verifyKbaDevice; + verifierLookup[QTYPE.DOB] = verifyKbaDob; + + for (let i = 0; i < sortedResponses.length; ++i) { + if (sortedResponses[i].questionID !== sortedExpected[i].questionID) { + return Q.reject(NO_MATCH); + } + + const verifier = verifierLookup[sortedExpected[i].questionType]; + + if (!verifier || !verifier(sortedResponses[i].answer, sortedExpected[i].answer)) { + debug(sortedResponses[i], '!==', sortedExpected[i]); + + return Q.reject(NO_MATCH); + } + } + + return Q.resolve(); // All matched +} + +/** + * Verifies that a postcode matches the expected answer. We compare after + * removing any spaces and uppercasing to avoid any formatting errors. + * + * @param {string} answer - The answer provided by the user. + * @param {string} expected - The expected answer stored in the system. + * + * @returns {boolean} - True if the answers match. + */ +function verifyKbaPostcode(answer, expected) { + const fixedAnswer = answer.replace(/ /g, '').toUpperCase(); + const fixedExpected = expected.replace(/ /g, '').toUpperCase(); + + return fixedAnswer === fixedExpected; +} + +/** + * Verifies that the correct last 3 digits of the card are given. We ensure + * both are strings, and are exactly equal. E.g. `'012'` is NOT equal to `12`. + * + * @param {string} answer - The answer provided by the user. + * @param {string} expected - The expected answer stored in the system. + * + * @returns {boolean} - True if the answers match. + */ +function verifyKbaCard(answer, expected) { + return _.isString(answer) && _.isString(expected) && answer === expected; +} + +/** + * Verifies that the correct transaction count is given. We have stored the + * exact number of transactions, but only expect answers in a smaller set of + * buckets (0, 1-2, 3-4, 5+), so check that fits. + * + * @param {string} answer - The answer provided by the user. + * @param {string} expected - The expected answer stored in the system. + * + * @returns {boolean} - True if the answers match. + */ +function verifyKbaTransactions(answer, expected) { + const expectedN = Number(expected); + switch (Number(answer)) { + case 0: + return expectedN === 0; + case 1: + return expectedN === 1 || expectedN === 2; + case 3: + return expectedN === 3 || expectedN === 4; + case 5: + return expectedN >= 5; + default: + return false; + } +} + +/** + * Verifies that the correct phone number for a device is given. We compare + * directly without any changes. + * + * @param {string} answer - The answer provided by the user. + * @param {string} expected - The expected answer stored in the system. + * + * @returns {boolean} - True if the answers match. + */ +function verifyKbaDevice(answer, expected) { + return answer === expected; +} + +/** + * Verifies that the date of birth is given. We compare using moment.js to + * convert to a date and checking that they are the same. This allows us to be + * a little flexible in the exact format (e.g. with or without a time) so long + * as it matches ISO 8601 (which we enforce). + * + * @param {string} answer - The answer provided by the user. + * @param {string} expected - The expected answer stored in the system. + * + * @returns {boolean} - True if the answers match. + */ +function verifyKbaDob(answer, expected) { + const momAnswer = moment(answer, moment.ISO_8601, true); // ISO 8601 strict mode. + const momExpected = moment(expected, moment.ISO_8601, true); // ISO 8601 strict mode. + + return momAnswer.isSame(momExpected, 'day'); // 'day' also matches month and year +} + +/** + * Starts a recovery process. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + */ +function startRecovery(req, res) { + const email = req.swagger.params.body.value.email; + + debug('Start recovery for: ', email); + + /** + * Step 1. Find the client, and check that we haven't hit the recovery rate limit + */ + const TOO_SOON = 'BRIDGE: Recovery attempted too soon. Must wait longer.'; + const clientP = references.getClientByEmail(email) + .then((client) => { + debug('Found client for ', email); + + // + // Check if recovery is alloed already (or no limit has been set) + // + const now = new Date(); + const allowAfter = _.get(client, 'PasswordManagement.0.RecoveryLimits.AllowAfter', now); + + if (now < allowAfter) { + return Q.reject({ + name: TOO_SOON, + validAfter: allowAfter + }); + } + + // + // Otherwise we are all good, so just keep going + // + return client; + }); + + /** + * Step 2. Create an email token. As it needs to be copied from an email + * we make it shorter than the usual ones + */ + const token = utils.randomCode(utils.paycodeString, utils.SMStokenLength); + + /** + * Step 3. Check if the client has any devices so we can tell if they + * need to do SMS confirmation or not. + */ + const deviceInfoP = clientP.then((client) => clientUtils.getDevicesInfo(client.ClientID)); + + /** + * Step 4. Send an email with the recovery token in it + */ + const EMAIL_FAIL = 'BRIDGE: Failed to send email'; + const emailP = Q.all([clientP, deviceInfoP]).spread((client) => { + // + // Build and send the email + // + const htmlEmail = templates.render( + 'account-recovery', + { + emailValidationCode: token + } + ); + + debug('Sending email...', token); + + return Q.nfcall( + mailer.sendEmail, + 'Live', + client.ClientName, + 'Bridge Account Recovery', + htmlEmail, + 'startRecovery' + ).catch(() => Q.reject(EMAIL_FAIL)); + }); + + /** + * Step 5. Increase the delay before the next allowed recovery + */ + const timeoutP = Q.all([clientP, emailP, deviceInfoP]).spread((client) => { + debug('Email sent. Updating client'); + const query = { + ClientID: client.ClientID + }; + + // + // Calculate the exponential backoff between attempts + // + const attempts = _.get(client, 'client.PasswordManagement.0.RecoveryLimits.Attempts', 0); + const delay = Math.pow(2, attempts) * utils.recoveryInitialDelay * 60; // Delay in seconds + const after = moment().add(delay, 'seconds').toDate(); + + // + // Update the RecoveryLimits of the client for next time. + // These will be reset on successful password reset. + // + const update = { + $set: { + 'PasswordManagement.0.RecoveryLimits.AllowAfter': after + }, + $inc: { + LastVersion: 1, + 'PasswordManagement.0.RecoveryLimits.Attempts': 1 + }, + $currentDate: { + LastUpdate: true + } + }; + + const options = { + upsert: false + }; + + return Q.nfcall( + mainDB.updateObject, + mainDB.collectionClient, + query, + update, + options, + false // Don't suppress errors - we expect this to succed + ).catch(() => Q.reject(FAILED_UPDATE_DB)); + }); + + /** + * Step 6. Setup the recovery session + */ + const sessionP = Q.all([clientP, deviceInfoP, timeoutP]) + .spread((client) => apiUtils.initRecoverySession(req, client)); + + /** + * Step 7. Return the result + * If the client has devices we return 202 ACCEPTED to say that we + * will need SMS authentication as a later step + * If the parents don't have devices, return 200 OK to say we only + * need email confirmation + */ + return Q.all([clientP, emailP, timeoutP, sessionP, deviceInfoP]) + .then((results) => { + debug('All done!'); + const sessionResponse = results[3]; + const deviceInfo = results[4]; + + // + // Setup the next expected state. This depends on whether we have + // devices to validate with an SMS token or not. + // + let nextState = STATES.WAITING_FOR_EMAIL_TOKEN; + let result = httpStatus.ACCEPTED; + + if (!deviceInfo.hasDevices) { + nextState = STATES.WAITING_FOR_EMAIL_TOKEN_PASSWORD; + result = httpStatus.OK; // No devices, so accept just an email token + } + + setNextState( + req, + nextState, + { + emailToken: token + } + ); + + return res.status(result).json(sessionResponse); + }) + .catch((error) => { + debug('Error starting recovery', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31131, 'Database Offline', true + ], + [ + references.ERRORS.INVALID_CLIENT, + httpStatus.BAD_REQUEST, 31132, 'Client not found', true + ], + [ + TOO_SOON, + httpStatus.TOO_MANY_REQUESTS, 31133, 'Too many recovery attempts.', true + ], + [ + EMAIL_FAIL, + httpStatus.BAD_GATEWAY, 31134, 'Failed to send validation email.' + ], + [ + FAILED_UPDATE_DB, + httpStatus.BAD_GATEWAY, 31135, 'Failed to update database.' + ], + [ + apiUtils.ERRORS.SESSION_REGEN_FAILED, + httpStatus.INTERNAL_SERVER_ERROR, 31136, 'Failed to create session.' + ] + ]; + const responseHandler = new responseUtils.ErrorResponses(responses); + + responseHandler.respond(res, error); + }); +} + +/** + * Completes the recovery process in the cases where we only need to confirm + * the email address. In these case, we send the new password along with the + * email token so that it is confirmed and the new password set in 1 step. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + */ +function completeRecoveryEmailPw(req, res) { + const emailToken = req.swagger.params.body.value.validationToken; + const newPassword = req.swagger.params.body.value.newPassword; + const clientID = req.session.data.clientID; + const dataP = getStateData(req, STATES.WAITING_FOR_EMAIL_TOKEN_PASSWORD); + + debug('Completing recover for: ', clientID); + + /** + * Step 1. Check the tokens match + */ + const matchP = dataP.then( + (data) => { + return emailToken === data.emailToken ? Q.resolve() : Q.reject(NO_MATCH); + } + ); + + /** + * Update the password + */ + const updateP = matchP.then(() => updatePassword(clientID, newPassword)); + + /** + * Check the results + */ + return Q.all([dataP, matchP, updateP]) + .then(() => { + debug('Password updated'); + res.status(httpStatus.OK).json(); + + // All good, so we also destroy the session so they have to log + // in normally to get a normal session + return req.session.destroy(); + }) + .catch((error) => { + debug('Error resetting password', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31141, 'Database Offline', true + ], + [ + WRONG_STATE, + httpStatus.FORBIDDEN, 31142, 'Operation not allowed' + ], + [ + NO_RETRIES, + httpStatus.FORBIDDEN, 31143, 'Too many failures' + ], + [ + NO_MATCH, + httpStatus.BAD_REQUEST, 31144, 'Invalid email token' + ], + [ + HASH_FAILED, + httpStatus.INTERNAL_SERVER_ERROR, 31145, 'Failed to initialise password' + ], + [ + FAILED_UPDATE_DB, + httpStatus.BAD_GATEWAY, 31146, 'Failed to update database.' + ] + ]; + const responseHandler = new responseUtils.ErrorResponses(responses); + + responseHandler.respond(res, error); + }); +} + +/** + * Confirms the email token, then produce a set of KBA questions for the client + * to answer. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + */ +function confirmRecoveryEmail(req, res) { + const clientID = req.session.data.clientID; + const token = req.swagger.params.body.value.validationToken; + const dataP = getStateData(req, STATES.WAITING_FOR_EMAIL_TOKEN); + + debug('Confirming email for: ', clientID); + + /** + * Step 1. Check the tokens match + */ + const matchP = dataP.then( + (data) => { + return token === data.emailToken ? Q.resolve() : Q.reject(NO_MATCH); + } + ); + + /** + * Step 2. Build the KBA questions + */ + const kbaP = getKba(clientID); + + /** + * Check if everything passed + */ + return Q.all([dataP, matchP, kbaP]) + .spread((data, match, kba) => { + debug('Email token validations'); + + setNextState( + req, + STATES.WAITING_FOR_ANSWERS, + { + answers: kba.answers + }); + + res.status(httpStatus.OK).json({ + questions: kba.questions + }); + }) + .catch((error) => { + debug('Error resetting password', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31151, 'Database Offline', true + ], + [ + WRONG_STATE, + httpStatus.FORBIDDEN, 31152, 'Operation not allowed' + ], + [ + NO_RETRIES, + httpStatus.FORBIDDEN, 31153, 'Too many failures' + ], + [ + NO_MATCH, + httpStatus.BAD_REQUEST, 31154, 'Invalid email token' + ] + ]; + const responseHandler = new responseUtils.ErrorResponses(responses); + + responseHandler.respond(res, error); + }); +} + +/** + * Confirm the answers to the KBA answers provided. Checks the provided phone + * number is correct for this client, and sends a reset token to the phone. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + */ +function confirmAnswers(req, res) { + const clientID = req.session.data.clientID; + const phoneNumber = req.swagger.params.body.value.DeviceNumber; + const answers = req.swagger.params.body.value.Answers; + const dataP = getStateData(req, STATES.WAITING_FOR_ANSWERS); + + debug('Confirming answers for: ', clientID); + + /** + * Validate the answers to the questions we asked + */ + const checkKbaP = dataP.then( + (data) => { + return verifyAnswers(answers, data.answers); + } + ); + + /** + * Check the device is a valid one for this client + */ + const deviceP = checkKbaP.then(() => references.getDevice(phoneNumber, clientID)); + + /** + * Send an SMS to that device + */ + const token = utils.randomCode(utils.paycodeString, utils.SMStokenLength); + const SMS_SEND_FAIL = 'BRIDGE: Failed to send SMS'; + const smsP = deviceP.then(() => { + debug('Sending reset SMS:', token); + + return Q.nfcall( + sms.sendSMS, + null, // or 'TEST' + phoneNumber, + 'Your Bridge verification code is ' + token + ).catch(() => Q.reject(SMS_SEND_FAIL)); + }); + + /** + * Check everything worked and reply ok + */ + return Q.all([dataP, checkKbaP, deviceP, smsP]) + .then(() => { + debug('KBA complete. Sent SMS token'); + + setNextState( + req, + STATES.WAITING_FOR_SMS_TOKEN_PASSWORD, + { + smsToken: token + }); + + return res.status(httpStatus.OK).json(); + }) + .catch((error) => { + debug('Error verifying KBA', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31161, 'Database Offline', true + ], + [ + WRONG_STATE, + httpStatus.FORBIDDEN, 31162, 'Operation not allowed' + ], + [ + NO_RETRIES, + httpStatus.FORBIDDEN, 31163, 'Too many failures' + ], + [ + NO_MATCH, + httpStatus.BAD_REQUEST, 31164, 'Incorrect answers' + ], + [ + SMS_SEND_FAIL, + httpStatus.BAD_GATEWAY, 31165, 'Failed to send recovery token' + ], + [ + references.ERRORS.INVALID_DEVICE, + httpStatus.BAD_REQUEST, 31166, + 'Device number is not registered for this client, or has been disabled', true + ] + ]; + const responseHandler = new responseUtils.ErrorResponses(responses); + + responseHandler.respond(res, error); + }); +} + +/** + * Completes the recovery process when we have an SMS validation token. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + */ +function completeRecoveryDevicePw(req, res) { + const newPassword = req.swagger.params.body.value.newPassword; + const token = req.swagger.params.body.value.validationToken; + const clientID = req.session.data.clientID; + const dataP = getStateData(req, STATES.WAITING_FOR_SMS_TOKEN_PASSWORD); + + debug('Completing recover via SMS for: ', clientID); + + /** + * Step 1. Check the tokens match, + * If not, check if we have any retries left + */ + const matchP = dataP.then( + (data) => { + return token === data.smsToken ? Q.resolve() : Q.reject(NO_MATCH); + } + ); + + /** + * Step 2. Update password + */ + const updateP = matchP.then(() => updatePassword(clientID, newPassword)); + + /** + * Check the results + */ + return Q.all([dataP, matchP, updateP]) + .then(() => { + debug('Password updated'); + res.status(httpStatus.OK).json(); + + // All good, so we also destroy the session so they have to log + // in normally to get a normal session + return req.session.destroy(); + }) + .catch((error) => { + debug('Error resetting password', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31171, 'Database Offline', true + ], + [ + WRONG_STATE, + httpStatus.FORBIDDEN, 31172, 'Operation not allowed' + ], + [ + NO_RETRIES, + httpStatus.FORBIDDEN, 31173, 'Too many failures' + ], + [ + NO_MATCH, + httpStatus.BAD_REQUEST, 31174, 'Invalid sms token' + ], + [ + HASH_FAILED, + httpStatus.INTERNAL_SERVER_ERROR, 31175, 'Failed to initialise password' + ], + [ + FAILED_UPDATE_DB, + httpStatus.BAD_GATEWAY, 31176, 'Failed to update database.' + ] + ]; + const responseHandler = new responseUtils.ErrorResponses(responses); + + responseHandler.respond(res, error); + }); +} diff --git a/node_server/swagger_api/controllers/api_tokens_controller.js b/node_server/swagger_api/controllers/api_tokens_controller.js new file mode 100644 index 0000000..ba96145 --- /dev/null +++ b/node_server/swagger_api/controllers/api_tokens_controller.js @@ -0,0 +1,345 @@ +/** + * @fileOverview File to manage integration authorisation token related operations + */ +'use strict'; + +const httpStatus = require('http-status-codes'); +const Q = require('q'); +const _ = require('lodash'); +const jwt = require('jsonwebtoken'); +const debug = require('debug')('webconsole-api:controllers:tokens'); + +const utils = require(global.pathPrefix + 'utils.js'); +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const references = require(global.pathPrefix + '../utils/references.js'); +const featureFlags = require(global.pathPrefix + '../utils/feature-flags/feature-flags.js'); +const responsesUtils = require(global.pathPrefix + '../utils/responses.js'); +const tokenUtils = require(global.pathPrefix + '../utils/tokens.js'); + +module.exports = { + listTokens: listTokens, + createToken: createToken, + deleteToken: deleteToken +}; + +const TOKENS_FEATURE_FLAG = 'tokens'; +const JWT_SECRET = require(global.configFile).integrationsTokenSecret; +const JWT_ALGORITHM = 'HS256'; // HMAC + SHA256 only +const JWT_ISSUER = 'bridge-v1'; // Issuer string +const JWT_OPTIONS = { + algorithm: JWT_ALGORITHM, + issuer: JWT_ISSUER, + noTimestamp: true +}; + +const FAILED_CREATE_JWT = 'BRIDGE: failed to create the JWT'; +const DATABASE_UPDATE_FAILED = 'BRIDGE: DB update failed'; + +/** + * Lists the tokens that belong to the current client. + * + * @param {Object} req - the request object + * @param {Object} res - the response object + */ +function listTokens(req, res) { + const clientID = req.session.data.clientID; + const clientP = references.getClient(clientID); + + // + // Iterate through the tokens we have, and turn them into JWTs for returning + // to the caller + // + const listP = clientP.then((client) => { + let jwtPromises = []; + const tokens = client.IntegrationTokens || []; + + for (let i = 0; i < tokens.length; ++i) { + // + // Define the payload + // + const jwtPayload = { + id: clientID, + token: tokens[i].token + }; + const name = tokens[i].name; + + // + // Call the JWT signing function + // + const jwtP = Q.nfcall(jwt.sign, jwtPayload, JWT_SECRET, JWT_OPTIONS) + .then((jwt) => { + debug('Token encoded', i); + return { + name: name, + token: jwt + }; + }) + .catch((error) => { + debug('Failed to encode', error, jwtPayload); + return Q.reject(FAILED_CREATE_JWT); + }); + + // + // Save the promises to the array that we will return + // + jwtPromises.push(jwtP); + } + return Q.all(jwtPromises); + }); + + // + // Send the response depending on results + // + Q.all([clientP, listP]) + .spread((client, tokens) => { + res.status(httpStatus.OK).json(tokens); + }) + .catch((error) => { + debug(' - error listing tokens', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31101, 'Database Offline', true + ], + [ + references.ERRORS.INVALID_CLIENT, + httpStatus.BAD_REQUEST, 31102, 'Client not found', true + ], + [ + FAILED_CREATE_JWT, + httpStatus.INTERNAL_SERVER_ERROR, 31103, 'Failed to generate tokens list.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }); +} + +/** + * Function to create a token for a merchant + * + * @param {Object} req - the request object + * @param {Object} res - the response object + */ +function createToken(req, res) { + // + // Check the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 999, + info: 'Client is not a merchant.' + }); + return; + } + + // + // Get the current user's details from the session + // + const clientID = req.session.data.clientID; + const tokenName = req.swagger.params.body.value.name; + const clientP = references.getClient(clientID); + + // + // Check that we have the feature flag enabled for this, and that we don't + // already have too many tokens. + // + const NOT_ENABLED = 'BRIDGE: Not enabled'; + const TOO_MANY_TOKENS = 'BRIDGE: Too many tokens'; + const enabledP = clientP.then((client) => { + if (!featureFlags.isEnabled(TOKENS_FEATURE_FLAG, client)) { + return Q.reject(NOT_ENABLED); + } else if ( + _.isArray(client.IntegrationTokens) && + client.IntegrationTokens.length >= utils.MaxIntegrationTokens + ) { + return Q.reject(TOO_MANY_TOKENS); + } else { + return client; // So we can cascade + } + }); + + // + // Push a random token into the IntegrationsTokens array on the client object + // + const token = utils.timeBasedRandomCode(); + const addedP = enabledP.then((client) => { + const query = { + _id: client._id + }; + const update = { + $push: { + IntegrationTokens: { + token: token, + name: tokenName + } + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + + return mainDB.collectionClient + .updateOne(query, update) + .then((res) => { + if (res.modifiedCount === 1) { + return Q.resolve(); + } else { + return Q.reject(DATABASE_UPDATE_FAILED); + } + }); + }); + + // + // Build a JWT based on the token (if it was added correctly) + // + const jwtPayload = { + id: clientID, + token: token + }; + const jwtP = addedP.then( + () => Q.nfcall(jwt.sign, jwtPayload, JWT_SECRET, JWT_OPTIONS) + .catch(() => Q.reject(FAILED_CREATE_JWT)) + ); + + Q.all([clientP, enabledP, addedP, jwtP]) + .then( + (results) => { + const jwt = results[3]; + res.status(httpStatus.OK).json({ + token: jwt + }); + }) + .catch((error) => { + debug(' - error creating token', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31111, 'Database Offline', true + ], + [ + references.ERRORS.INVALID_CLIENT, + httpStatus.BAD_REQUEST, 31112, 'Client not found', true + ], + [ + NOT_ENABLED, + httpStatus.BAD_REQUEST, 31113, 'Tokens not enabled.' + ], + [ + TOO_MANY_TOKENS, + httpStatus.CONFLICT, 31116, 'Too many tokens.' + ], + [ + DATABASE_UPDATE_FAILED, + httpStatus.BAD_GATEWAY, 31114, 'Failed to store token.' + ], + [ + FAILED_CREATE_JWT, + httpStatus.INTERNAL_SERVER_ERROR, 31115, 'Failed to produce final token.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }); +} + +/** + * Deletes a token that belongs to the current client. + * + * @param {Object} req - the request object + * @param {Object} res - the response object + */ +function deleteToken(req, res) { + // + // Get the current user's details from the session + // + const clientID = req.session.data.clientID; + const token = req.swagger.params.token.value; + + // + // Validate the token + // + const DIFFERENT_CLIENT = 'BRIDGE: Token belongs to a different client'; + let validateP = tokenUtils.validateToken(token).then((result) => { + // + // The token is valid, but may belong to a different client + // + if (result.client.ClientID !== clientID) { + return Q.reject(DIFFERENT_CLIENT); + } else { + return result; + } + }); + + // + // Delete the token from the list + // + let deleteP = validateP.then((result) => { + const query = { + _id: result.client._id + }; + const update = { + $pull: { + IntegrationTokens: { + token: result.decoded.token + } + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + + return mainDB.collectionClient + .updateOne(query, update) + .then((res) => { + if (res.modifiedCount === 1) { + return Q.resolve(); + } else { + return Q.reject(DATABASE_UPDATE_FAILED); + } + }); + }); + + Q.all([validateP, deleteP]) + .then(() => { + res.status(httpStatus.OK).json(); + }) + .catch((error) => { + debug(' - error creating token', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 31121, 'Database Offline', true + ], + + // + // Not that we give a similar error response to a number of cases + // to reduce the amount of information we return about tokens + // + [ + tokenUtils.ERRORS.TOKEN_INVALID, + httpStatus.BAD_REQUEST, 31122, 'Invalid Token' + ], + [ + tokenUtils.ERRORS.CLIENT_NOT_FOUND, + httpStatus.BAD_REQUEST, 31123, 'Invalid Token' + ], + [ + DIFFERENT_CLIENT, + httpStatus.BAD_REQUEST, 31124, 'Invalid Token' + ], + [ + DATABASE_UPDATE_FAILED, + httpStatus.BAD_GATEWAY, 31125, 'Failed to delete token.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }); +} diff --git a/node_server/swagger_api/controllers/api_transactions_controller.js b/node_server/swagger_api/controllers/api_transactions_controller.js new file mode 100644 index 0000000..3f0f010 --- /dev/null +++ b/node_server/swagger_api/controllers/api_transactions_controller.js @@ -0,0 +1,248 @@ +/** + * Controller to manage the transactions functions + */ +'use strict'; + +var _ = require('lodash'); +var httpStatus = require('http-status-codes'); +var mongodb = require('mongodb'); +var debug = require('debug')('webconsole-api:controllers:transactions'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); + +module.exports = { + getTransactions: getTransactions, + getTransaction: getTransaction +}; + +/** + * Get the transaction history + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getTransactions(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var limit = req.swagger.params.limit.value; + var skip = req.swagger.params.skip.value; + var minDate = req.swagger.params.minDate.value; + var maxDate = req.swagger.params.maxDate.value; + var transactionTypes = req.swagger.params.transactionTypes.value; + var accountId = req.swagger.params.accountId.value; + + var query = { + ClientID: clientID + }; + // + // Add date limits if included + // + if (minDate || maxDate) { + query.SaleTime = {}; + if (minDate) { + query.SaleTime.$gte = minDate; + } + if (maxDate) { + query.SaleTime.$lte = maxDate; + } + } + + // + // Add accountId limits if any + // + if (accountId) { + query.AccountID = accountId; + } + + // + // Limit to specific transaction types if requested + // + if (transactionTypes && _.isArray(transactionTypes)) { + query.TransactionType = {}; + query.TransactionType.$in = transactionTypes; + } + + // + // Define the projection based on the Swagger definition + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + false // prevent _id being included + ); + + // + // Make the query. Not limit & skip have defaults defined in the + // swagger definition, so will always exist even if not requested + // + mainDB.collectionTransactionHistory.find(query, projection) + .skip(skip) + .limit(limit) + .sort({'SaleTime': -1}) // Hard-coded reverse sort by time + .toArray(function(err, items) { + if (err) { + debug('- failed to getTransactions', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 197, + info: 'Database offline' + }); + } else { + // + // Move invoice number to the top level + // + for (var i = 0; i < items.length; ++i) { + if (!_.isUndefined(items[i].MerchantInvoiceNumber)) { + items[i].MerchantInvoiceNumber = + items[i].MerchantInvoiceNumber.InvoiceNumber; + } + } + + res.status(httpStatus.OK).json(items); + } + }); +} + +/** + * Gets the transaction details for a specific transaction. The id is the + * `TransactionID` from the getTransactions() summary items. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getTransaction(req, res) { + // + // Get the query params from the request and the session + // + var clientID = req.session.data.clientID; + var transactionId = req.swagger.params.objectId.value; + + // + // Build the query. The limits are: + // - Must match the id of the item we are looking for + // - Current user must be the customer or merchant (for security, to protect + // against Insecure Direct Object References). + // + var query = { + _id: mongodb.ObjectID(transactionId), + $or: [ + {CustomerClientID: clientID}, + {MerchantClientID: clientID} + ] + }; + + // + // Depending on whether the user is a customer or merchant we convert some + // of the parameters to different names. This table defines these + // conversions. + // Note: any items not in this table, but in the swagger definition are + // assumed to come direct from the database unchanged. + // + const IS_CUSTOMER_INDEX = 0; + const IS_MERCHANT_INDEX = 1; + var conversions = { + // Structure is: + // ResponseField: [IsCustomerDbField, IsMerchantDbField] + // + OtherDisplayName: ['MerchantDisplayName', 'CustomerDisplayName'], + OtherSubDisplayName: ['MerchantSubDisplayName', 'CustomerSubDisplayName'], + OtherImage: ['MerchantImage', 'CustomerImage'], + MyLocation: ['CustomerLocation', 'MerchantLocation'] + }; + + // + // Define the fields based on the Swagger definition. + // When going through the swagger definitions we check in the conversion + // table above, and fill in both fields so we have the required data to + // later do the conversion. + // Note: we allow _id here because the user provided it to us so no point + // hiding it. + // + var projection = { + // Initialise with client names to match against later on + MerchantClientID: 1, + CustomerClientID: 1 + }; + _.forEach( + req.swagger.operation.responses['200'].schema.properties, + _.bind(function(value, key, collection) { + if (conversions.hasOwnProperty(key)) { + // Has a conversion: include both + this[conversions[key][IS_CUSTOMER_INDEX]] = 1; + this[conversions[key][IS_MERCHANT_INDEX]] = 1; + } else { + // Doesn't have a conversion: include this one directly + this[key] = 1; + } + }, projection) + ); + + // + // Build the options to encapsulate the projection + // + var options = { + fields: projection, + comment: 'WebConsole:getTransaction' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionTransaction, query, options, false, + function(err, item) { + if (err) { + debug('- failed to getTransaction', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 197, + info: 'Database offline' + }); + } else if (item === null) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 192, + info: 'Not found' + }); + } else { + // + // Need to handle the conversions. Three step process: + // 1. Am 'I' the customer or merchant? + // - Must be one or other due to the search condition + // 2. Copy appropriate value from the DB name to the response name + // 3. Delete all DB name items + // + var conversionIndex = IS_CUSTOMER_INDEX; + if (item.MerchantClientID === clientID) { + conversionIndex = IS_MERCHANT_INDEX; + } + + _.forEach( + conversions, + function(value, key, collection) { + item[key] = item[value[conversionIndex]]; + }); + + _.forEach( + conversions, + function(value, key, collection) { + delete item[value[IS_CUSTOMER_INDEX]]; + delete item[value[IS_MERCHANT_INDEX]]; + }); + + // Delete the two hard-coded names we got for matching above + delete item.CustomerClientID; + delete item.MerchantClientID; + + // + // Move invoice number to the top level + // + if (!_.isUndefined(item.MerchantInvoiceNumber)) { + item.MerchantInvoiceNumber = + item.MerchantInvoiceNumber.InvoiceNumber; + } + + res.status(httpStatus.OK).json(item); + } + }); +} diff --git a/node_server/swagger_api/controllers/api_users_controller.js b/node_server/swagger_api/controllers/api_users_controller.js new file mode 100644 index 0000000..b6a5790 --- /dev/null +++ b/node_server/swagger_api/controllers/api_users_controller.js @@ -0,0 +1,1877 @@ +/** + * Controller to manage the users functions + */ +'use strict'; + +var _ = require('lodash'); +var Q = require('q'); +var templates = require(global.pathPrefix + '../utils/templates.js'); +var httpStatus = require('http-status-codes'); +var mongodb = require('mongodb'); +var utils = require(global.pathPrefix + 'utils.js'); +var debug = require('debug')('webconsole-api:controllers:users'); +var Client = require(global.pathPrefix + '../utils/client/client.js').Client; +var clientUtils = require(global.pathPrefix + '../utils/client/client.js'); +var promiseUtil = require(global.pathPrefix + '../utils/promises.js'); +var hashUtil = require(global.pathPrefix + '../utils/hashing.js'); +var credentialsUtil = require(global.pathPrefix + '../utils/credentials.js'); +var swaggerUtils = require(global.pathPrefix + '../utils/swaggerUtils.js'); +var anon = require(global.pathPrefix + '../utils/anon.js'); +var references = require(global.pathPrefix + '../utils/references.js'); +var responsesUtils = require(global.pathPrefix + '../utils/responses.js'); +var diligence = require(global.pathPrefix + '../utils/diligence/diligence.js'); +const featureFlags = require(global.pathPrefix + '../utils/feature-flags/feature-flags.js'); +const apiSecurity = require('../api_security.js'); +var apiUtil = require('../api_utils.js'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var config = require(global.configFile); + +module.exports = { + createUser: createUser, + confirmEmail: confirmEmail, + completeRegistration: completeRegistration, + denyEmail: denyEmail, + resendConfirmEmail: resendConfirmEmail, + changeEmail: changeEmail, + revertChangedEmail: revertChangedEmail, + changePassword: changePassword, + getUser: getUser, + getKYC: getKYC, + updateKYC: updateKYC, + getMerchant: getMerchant, + updateMerchant: updateMerchant +}; + +const VAT_FLAG = 'vat'; + +/** + * Function to create a user. + * We check that the user doesn't already exist, then add them to the + * + * Note: The controller is called after the validator middleware so we don't + * need to validate the format of the parameters. + * + * @param {Object} req - Express request object, with additional information + * from Swagger. Particularly useful is `req.swagger` + * which contains information on this specific request. + * @param {Object} res - Express response object + */ +function createUser(req, res) { + debug('api/controllers/users/createUser called:'); + + // + // Get the values from the request + // + var email = req.swagger.params.body.value.email; + var password = req.swagger.params.body.value.password; + var operator = req.swagger.params.body.value.operator; + + // + // Encode the password + // + var encodeP = encodePassword(password); + + // + // Promise chain for the asynchronous processing of the rest of the steps. + // Errors are handled by adding the requested response to the err then + // using it as the value of the rejected promise. Later error handlers + // check for the existence of that field, and don't change the error if + // a previous error exists. + // + + // + // Wait for the password to be hashed, then add the user to the client db. + // Note that we bind in most of the parameters as the promise only + // provides the hashed password from above. + // + encodeP.then(addToDb.bind(undefined, email, operator)) + + // + // Wait for the insert to be tried. If it worked send the welcome email, + // else report the error. + // + .then(sendWelcomeEmail.bind(undefined, 'webconsole:createUser'), failedAddUser) + + // + // Get the result of the email sending. If it worked then report success, + // else report the error + // + .then(returnSuccess.bind(undefined, res), failedSendEmail) + + // + // Catch any unknown/unexpected errors + // + .catch(promiseUtil.sendErrorResponse.bind(undefined, res)) + + // + // Always have to end on done to ensure anything else is caught + // + .done(); +} + +/** + * Attempts to confirm the users email address by validating the token + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function confirmEmail(req, res) { + var token = req.swagger.params.body.value.emailValidationToken; + var clientId = req.session.data.client; + + // + // Query to check the token matches the given one, and the token hasn't + // expired. + // + var confirmValidQuery = { + _id: mongodb.ObjectID(clientId), + EMailValidationToken: token, + EMailValidationTokenExpiry: {$gt: new Date()} + }; + + // + // If this were to be found, define the updates to set the client's email + // as validated + // + var validateUpdates = { + $set: { + EMailValidationToken: '', // Token cleared + EMailValidationTokenExpiry: '', // No expiry either + LastUpdate: new Date() // Last updated now + }, + $bit: { + ClientStatus: {or: utils.ClientEmailVerifiedMask} // Set the flag + }, + $inc: { + LastVersion: 1 // Increment the document version + } + }; + var validateOptions = { + upsert: false, + multi: false + }; + + // + // Get the database to query for a record with a matching client id, that + // also matches the unexpired email validation token. If matched, the + // database will update the record to confirm it is validated. + // + var validatePromise = Q.nfcall( + mainDB.updateObject, + mainDB.collectionClient, + confirmValidQuery, // Look for a matching record + validateUpdates, // ...and update it to these values + validateOptions, // don't upsert + false + ); + + // + // Check the results and return success or failure as appropriate + // + validatePromise + .then(function success(result) { + if (result.result.n === 0) { + // + // No documents matched the criteria. + // This means that one of the following is true: + // - No client with the given id + // - unlikely because we got it from the current session + // - Email token doesn't match + // - user typo, or email already confirmed so no token + // - most likely + // - Email token has expired + // - unlikely, but possible + // + res.status(httpStatus.BAD_REQUEST).json({ + code: 38, + info: 'Invalid or expired email validation token' + }); + } else { + res.status(httpStatus.OK).json(); + } + }) + .catch(function fail(error) { + // + // Running the query failed (i.e. it didn't run because of a + // network error etc, NOT that it ran and found nothing. + // + debug('-- error validating email: ', error); + if ( + error && + error.hasOwnProperty('name') && + error.name === 'MongoError' + ) { + // + // Mongo Error + // + res.status(httpStatus.BAD_GATEWAY).json({ + code: 40, + info: 'Database Unavailable' + }); + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unspecified error' + }); + } + }) + .done(); // End the promise chain +} + +/** + * Completes a partial registration that was initiated through the integrations + * API. This requires the email token, and the client must not have a password + * specified already. + * + * @param {Object} req - express request object + * @param {Object} res - express response object + */ +function completeRegistration(req, res) { + const token = req.swagger.params.body.value.emailValidationToken; + const email = req.swagger.params.body.value.email; + const password = req.swagger.params.body.value.password; + + // + // Need to encode the password for saving in the db + // + let encodeP = encodePassword(password); + + // + // Find the Client of interest and add the hashed password to them + // + const CLIENT_NOT_FOUND_OR_TOKEN_INVALID = 'BRIDGE: Client not found or token invalid'; + let completeP = encodeP.then((pwInfo) => { + // + // To complete a registration, the current client needs to: + // - Exist, based on the email address passed in + // - Have a matching registration token + // - Not have the token be expired + // - Not already have a password + // + const confirmValidQuery = { + ClientName: email, + EMailValidationToken: token, + EMailValidationTokenExpiry: { + $gt: new Date() + }, + Password: '', + ClientSalt: '' + }; + + // + // If this were to be found, define the updates to set the client's email + // as validated + // + const validateUpdates = { + $set: { + EMailValidationToken: '', // Token cleared + EMailValidationTokenExpiry: '', // No expiry either + Password: pwInfo.hash, // Password added + ClientSalt: pwInfo.salt // Password salt added + }, + $bit: { + ClientStatus: { + or: utils.ClientEmailVerifiedMask // Set the email verified flag + } + }, + $inc: { + LastVersion: 1 // Increment the document version + }, + $currentDate: { + LastUpdate: true + } + }; + const validateOptions = { + upsert: false, + multi: false + }; + + // + // Get the database to query for a record with a matching client id, that + // also matches the unexpired email validation token. If matched, the + // database will update the record to confirm it is validated. + // + return Q.nfcall( + mainDB.updateObject, + mainDB.collectionClient, + confirmValidQuery, // Look for a matching record + validateUpdates, // ...and update it to these values + validateOptions, // don't upsert + false + ).then((updateResult) => { + if (updateResult.result.n === 0) { + // + // No documents matched the criteria. + // This means that one of the following is true: + // - No client with the given id + // - unlikely because we got it from the current session + // - Email token doesn't match + // - user typo, or email already confirmed so no token + // - most likely + // - Email token has expired + // - unlikely, but possible + // - Password was previously set but the token wasn't cleared + // - very unlikely + // + return Q.reject(CLIENT_NOT_FOUND_OR_TOKEN_INVALID); + } else { + // + // Success + // + return Q.resolve(); + } + }); + }); + + // + // Check the results and return success or failure as appropriate + // + Q.all([encodeP, completeP]) + .then(() => { + // All good, so send success + res.status(httpStatus.OK).json(); + }) + .catch((error) => { + debug(' - error updating KYC', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 999, 'Database Offline', true + ], + [ + CLIENT_NOT_FOUND_OR_TOKEN_INVALID, + httpStatus.BAD_REQUEST, 999, 'Client not found or invalid token' + ], + [ + hashUtil.ERRORS.UNKNOWN_ALGO, + httpStatus.INTERNAL_SERVER_ERROR, 999, 'Error encoding passsword' + ], + [ + hashUtil.ERRORS.HASH_FAILED, + httpStatus.INTERNAL_SERVER_ERROR, 999, 'Error encoding passsword' + ], + [ + hashUtil.ERRORS.SALT_FAILED, + httpStatus.INTERNAL_SERVER_ERROR, 999, 'Error encoding passsword' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }); +} + +/** + * Called when the client denies that they have signed up for Bridge. It copies + * the client information to the ClientArchive and then deletes it from Client. + * It then does the same with any deviecs associated with that email. + * NOTE: this is only allowed if they haven't confirmed their email previously. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function denyEmail(req, res) { + var email = req.swagger.params.body.value.email; + + // + // This is a 3 step process: + // Step 1: Find the appropriate client + // Note: they must not have verified their email previously and have + // never logged in via a moble device + // + var query = { + ClientName: email, + ClientStatus: { + $bitsAllClear: utils.ClientEmailVerifiedMask + }, + FirstLogin: 1 + }; + + const NOT_FOUND = 'BRIDGE: NOT FOUND'; + var findPromise = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + query, + undefined, + false + ).then(function(client) { + if (!client) { + return Q.reject({name: NOT_FOUND}); + } + return client; + }); + + // + // Step 2: Copy the client to the archive + // + var copyPromise = findPromise.then(function(client) { + // Copy the old _id to OldClientID: be aware that ClientID is used for something + // else and should not be overwritten. + client.OldClientID = client._id.toString(); + delete client._id; + + // + // Remove the Password entirely for future security reasons. + // + client.Password = ''; + client.ClientSalt = ''; + + // Update the LastUpdate + client.LastUpdate = new Date(); + + return Q.nfcall( + mainDB.addObject, + mainDB.collectionClientArchive, + client, + undefined, + false + ); + }); + + // + // Step 3: delete from the client collection + // + var deletePromise = copyPromise.then(function() { + // We use the same query as before in case there was a race + // condition and the client changed (e.g. confirmed email) in the + // middle of this + return Q.nfcall( + mainDB.removeObject, + mainDB.collectionClient, + query, + undefined, + false); + }); + + // + // Step 4: find the first device belonging to the client. + // note: you can't add multiple devices until you login (which blocks delete) + // so "first" should be synonymous with "only". + // + var findDeviceP = findPromise.then(function(client) { + let deviceQ = { + ClientID: client.ClientID + }; + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionDevice, + deviceQ, + undefined, + false + ); + }); + + // + // Step 5: archive the device + // note: we wait until the client has been archived before we start + // so we don't risk the client being left, but no devices to + // login with. + // + var archiveDeviceP = Q.all([findDeviceP, deletePromise]).spread(function(device) { + if (!device) { + // Nothing to archive + return Q.resolve(); + } + + let archiveDevice = _.clone(device); + archiveDevice.DeviceIndex = archiveDevice._id.toString(); + delete archiveDevice._id; + archiveDevice.DeviceAuthorisation = ''; + archiveDevice.DeviceSalt = ''; + archiveDevice.PendingHMAC = ''; + archiveDevice.CurrentHMAC = ''; + archiveDevice.LastUpdate = new Date(); + + return Q.nfcall( + mainDB.addObject, + mainDB.collectionDeviceArchive, + archiveDevice, + undefined, + false + ); + }); + + // + // Step 6: delete the device now that it is archived + // + var deleteDeviceP = Q.all([findDeviceP, archiveDeviceP]).spread(function(device) { + if (!device) { + // Nothing to delete + return Q.resolve(); + } + + let removeQ = { + _id: device._id + }; + return Q.nfcall( + mainDB.removeObject, + mainDB.collectionDevice, + removeQ, + undefined, + false + ); + }); + + // + // Run all the steps and check they pass + // + Q.all([findPromise, copyPromise, deletePromise, findDeviceP, archiveDeviceP, deleteDeviceP]) + .then(function() { + // All good + res.status(200).json(); + }) + .catch(function(error) { + debug('-- error denying email: ', error); + if ( + error && + error.hasOwnProperty('name') + ) { + switch (error.name) { + case NOT_FOUND: + // No account with that name was found + res.status(httpStatus.NOT_FOUND).json({ + code: 58, + info: 'Email address already confirmed, or not found' + }); + break; + + case 'MongoError': + // Mongo Error + res.status(httpStatus.BAD_GATEWAY).json({ + code: 53, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }) + .done(); // Catch all +} + +/** + * Function to request resending of the email address confirmation email. + * This requires that the client is logged in so that we send it to the right + * person. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function resendConfirmEmail(req, res) { + var email = req.session.data.email; + var id = req.session.data.client; + + // + // This is a 3 step process + // + // Step 1: generate a new confirmation code + // This token will be valid for 7 days + // + var emailToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + var emailTokenExpiry = new Date(); + emailTokenExpiry.setDate(emailTokenExpiry.getDate() + 7); + + // + // Step 2: Run an update query to store the new token for the client + // NOTE: this deliberately replaces any existing token so that + // old emails can't be used to validate the account. + // + var query = { + _id: mongodb.ObjectID(id), + ClientStatus: { + $bitsAllClear: utils.ClientEmailVerifiedMask // Must not be verified already + } + }; + var update = { + $set: { + EMailValidationToken: emailToken, + EMailValidationTokenExpiry: emailTokenExpiry, + LastUpdate: new Date() + }, + $inc: { + LastVersion: 1 + } + }; + var options = { + upsert: false + }; + + var updatePromise = Q.nfcall( + mainDB.updateObject, + mainDB.collectionClient, + query, + update, + options, + false + ); + + // + // Step 3: Send the new email + // + const FAILED_UPDATE = 'BRIDGE: FAILED TO UPDATE TOKEN'; + var sendPromise = updatePromise.then(function(results) { + if (results.result.n === 0) { + // Didn't find any accounts to update with the given id that + // aren't already verified + return Q.reject({name: FAILED_UPDATE}); + } else { + // + // Call the send email function. It's expecting an array of + // 1 client (based on the other calls to it), so build that. + // + var client = { + ClientName: email, + EMailValidationToken: emailToken + }; + return sendWelcomeEmail('webconsole:resendConfirmEmail', [client]); + } + }); + + // + // Run all the steps and return the results + // + Q.all([updatePromise, sendPromise]) + .then(function() { + // Success + res.status(200).json(); + }) + .catch(function(error) { + debug('-- error adding address: ', error); + if (error && error.hasOwnProperty('name')) { + switch (error.name) { + case FAILED_UPDATE: + // Couldn't find the user in the database to update + // them, or they have already verified their account + res.status(httpStatus.BAD_REQUEST).json({ + code: 87, + info: 'Account already verified (or not found)' + }); + break; + + case 'MongoError': + // + // Mongo Error + // + res.status(httpStatus.BAD_GATEWAY).json({ + code: 85, + info: 'Database Unavailable' + }); + break; + + default: + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + break; + } + } else { + // + // Unknown error + // + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unexpected error' + }); + } + }) + .done(); // Catch all +} + +/** + * Function to request changing the password. The user must (a) be logged in, + * and (b) re-confirm their password (for security). Thus this only works for + * users who still know their current password + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function changePassword(req, res) { + debug('Changing password'); + var email = req.session.data.email; + var currentpw = req.swagger.params.body.value.currentPassword; + var newpw = req.swagger.params.body.value.newPassword; + + // + // Step 1. Validate the current pw + // + var validateP = credentialsUtil.validateRawPassword(email, currentpw); + + // + // Step 2. encode the new password. + // This can run in parallel with validating the current password + // because we don't do anything with the result until later + // + var encodeP = encodePassword(newpw); + + // + // Step 3. If the credentials were valid, and the password encoded + // then send an email to the user to tell them the password is + // being changed. + // + const CANT_SEND_EMAIL = 'Failed to send password change email'; + var emailP = Q.all([validateP, encodeP]).then(function() { + debug(' - sending password changed email'); + var htmlEmail = templates.render('password-changed-web'); + var subject = 'Bridge Password Changed'; + + // + // Always send emails + // + var mode = 'Live'; + + return Q.nfcall( + mailer.sendEmail, + mode, + email, + subject, + htmlEmail, + 'users/changePassword') + .catch(function(error) { + return Q.reject(CANT_SEND_EMAIL); + }); + }); + + // + // Step 4. Update the password in the database + // + const BRIDGE_UPDATE_FAILED = 'Bridge: Update Failed'; + var updateP = Q.all([encodeP, emailP]).then(function(results) { + debug(' - updating database'); + var passwordInfo = results[0]; + + var query = { + ClientName: email + }; + var update = { + $set: { + Password: passwordInfo.hash, + ClientSalt: passwordInfo.salt, + LastUpdate: new Date() + }, + $inc: { + LastVersion: 1 + } + }; + var options = { + upsert: false, + returnOriginal: false + }; + + return Q.ninvoke( + mainDB.collectionClient, + 'findOneAndUpdate', + query, + update, + options + ).then(function(result) { + if (result.ok !== 1 || !result.value) { + return Q.reject(BRIDGE_UPDATE_FAILED); + } else { + return result.value; + } + }); + }); + + // + // Step 5. Refresh the session so there can't be any accidental use + // of the old session + // + var sessionP = updateP.then(function(client) { + debug(' - refreshing session'); + return apiUtil.initSession(req, client).then((response) => { + // Set the level to basic. + req.session.data.level = apiSecurity.SESSION_TYPES.BASIC; + return response; + }); + }); + + // + // Step 6. Wait for all the promises then return the result + // + Q.all([validateP, encodeP, emailP, updateP, sessionP]) + .then(function(results) { + debug(' - password update complete'); + var response = results[4]; // The sessionP response + res.status(httpStatus.OK).json(response); + }) + .catch(function(error) { + debug(' - error updating password', error); + // + // Handle the error appropriately + // + if (error.hasOwnProperty('name') && error.name === 'MongoError') { + // Mongo Error + res.status(httpStatus.BAD_GATEWAY).json({ + code: 420, + info: 'Database offline' + }); + return; + } + + switch (error) { + // + // Credentials verification errors + // + case hashUtil.ERRORS.NO_MATCH: + case credentialsUtil.ERRORS.NOT_FOUND: + // These errors get a generic Login Failed error to avoid + // leaking information about why + res.status(httpStatus.UNAUTHORIZED).json({ + code: 106, + info: 'Failed to validate current user and password.' + }); + break; + + case credentialsUtil.ERRORS.BARRED: + res.status(httpStatus.FORBIDDEN).json({ + code: 117, + info: 'Client barred' + }); + break; + + case credentialsUtil.ERRORS.TOO_MANY_ATTEMPTS: + res.status(httpStatus.FORBIDDEN).json({ + code: 410, + info: 'Too many failed password attempts' + }); + break; + + case credentialsUtil.ERRORS.CANT_UPDATE_ATTEMPTS_SUCCESS: + case credentialsUtil.ERRORS.CANT_UPDATE_ATTEMPTS_FAIL: + res.status(httpStatus.BAD_GATEWAY).json({ + code: 401, + info: 'Database offline' + }); + break; + + case credentialsUtil.ERRORS.CANT_SEND_WARNING_EMAIL: + res.status(httpStatus.BAD_GATEWAY).json({ + code: 409, + info: 'Unable to send e-mail.' + }); + break; + + // Password hash generation errors + case hashUtil.ERRORS.HASH_FAILED: + case hashUtil.ERRORS.UNKNOWN_ALGO: + case hashUtil.ERRORS.SALT_FAILED: + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: 419, + info: 'Error encrypting new password' + }); + break; + + // Cant send email to say password changed + case CANT_SEND_EMAIL: + res.status(httpStatus.BAD_GATEWAY).json({ + code: 421, + info: 'Unable to send e-mail.' + }); + break; + + // Cant update the account (disappeared?) + case BRIDGE_UPDATE_FAILED: + res.status(httpStatus.BAD_REQUEST).json({ + code: 106, + info: 'Account not found' + }); + break; + + // Failed to regenerate the session + case apiUtil.ERRORS.SESSION_REGEN_FAILED: + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: 30010, + info: 'Error regenerating session. User must login again.' + }); + break; + + // Other errors + default: + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + code: -1, + info: 'Unspecified error' + }); + } + }) + .done(); +} + +/** + * Function to cheange the email address for this user. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function changeEmail(req, res) { + debug('Changing email'); + const clientID = req.session.data.clientID; + const oldEmail = req.session.data.email; + const newEmail = req.swagger.params.body.value.email; + + // + // Step 1. Get the current client and check there isn't already a + // change pending as this would allow a scammer to change the email + // multiple times to overwrite the revert address + // + const RECENTLY_CHANGED = 'BRIDGE: RECENTLY CHANGED'; + let clientP = references.getClient(clientID).then(function(client) { + if ( + client.PreviousEMailValidationTokenExpiry && + client.PreviousEMailValidationTokenExpiry > new Date()) { + return Q.reject(RECENTLY_CHANGED); + } + return client; + }); + + // + // Step 2. Check that the new address is not already in use. + // + const IN_USE = 'BRIDGE: ADDRESS IN USE'; + var query = { + ClientName: newEmail + }; + var options = { + comment: 'webconsole:revertChangedEmail' + }; + + let emailNotInUseP = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + query, + options, + false // Don't suppress errors + ).then((client) => client ? Q.reject(IN_USE) : true); // Reject if another client exists + + // + // Step 3. Generate a random ID for reverting the email change, and another + // for confirming the new email address. + // + const newEmailToken = clientUtils.generateEmailToken(); + const revertEmailToken = clientUtils.generateEmailToken(); + + // + // Step 4. If the update is allowed, send an email to the old address. + // We want to make sure this at least sends ok, as this may be + // the only warning someone gets of an attempt to take over the account. + // + const CANT_SEND_EMAIL = 'Failed to send email change email'; + let emailP = Q.all([clientP, emailNotInUseP]).then(function() { + debug(' - sending email changed email'); + return mailer.sendEmailChangedEmails( + oldEmail, + newEmail, + revertEmailToken, + newEmailToken, + '', //Mode: always send + 'webconsole.changeEmail' + ).catch(function(error) { + return Q.reject(CANT_SEND_EMAIL); + }); + }); + + // + // Step 5. Update the emails etc. in the Client object in the database + // + const BRIDGE_UPDATE_FAILED = 'Bridge: Update Failed'; + let updateP = Q.all([clientP, emailP]).then(function(results) { + debug(' - updating database'); + + var query = { + ClientID: clientID + }; + var update = { + $set: { + ClientName: newEmail, + EMailValidationToken: newEmailToken.token, + EMailValidationTokenExpiry: newEmailToken.expiry, + PreviousEmail: oldEmail, + PreviousEMailValidationToken: revertEmailToken.token, + PreviousEMailValidationTokenExpiry: revertEmailToken.expiry + }, + $bit: { + // Clear the email verified flag so they have to verify the new email + // Note that we have to ignore JSHint's complaint about the use of xor (~). + ClientStatus: {and: ~utils.ClientEmailVerifiedMask} // jshint ignore:line + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + var options = { + upsert: false, + returnOriginal: false + }; + + return Q.ninvoke( + mainDB.collectionClient, + 'findOneAndUpdate', + query, + update, + options + ).then(function(result) { + if (result.ok !== 1 || !result.value) { + return Q.reject(BRIDGE_UPDATE_FAILED); + } else { + return result.value; + } + }); + }); + + // + // Step 5. Refresh the sessions so there can't be any accidental use + // of the old session + // + var resetSessionsP = updateP.then((client) => resetSessions(req, client)); + + // + // Step 6. Wait for all the promises then return the result + // NOTE that we don't wait for the session reset promise because we + // can't really do anything if it fails, and the address has already + // been changed. + // + Q.all([clientP, emailNotInUseP, emailP, updateP]) + .then(function(results) { + debug(' - email update complete'); + res.status(httpStatus.OK).json(); + }) + .catch(function(error) { + debug(' - error updating email', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 30801, 'Database Offline', true + ], + [ + references.ERRORS.INVALID_CLIENT, + httpStatus.BAD_REQUEST, 30802, 'Client not found', true + ], + [ + IN_USE, + httpStatus.BAD_REQUEST, 30803, 'Email address already in use.' + ], + [ + RECENTLY_CHANGED, + httpStatus.CONFLICT, 30804, 'Email address change pending. Try again later.' + ], + [ + CANT_SEND_EMAIL, + httpStatus.BAD_GATEWAY, 30805, 'Unable to send e-mail.' + ], + [ + BRIDGE_UPDATE_FAILED, + httpStatus.BAD_REQUEST, 30806, 'Unable to change email address' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + + }) + .done(); +} + +/** + * Function to revert the email change. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function revertChangedEmail(req, res) { + debug('Reverting Changed email'); + const token = req.swagger.params.body.value.emailValidationToken; + + // + // Step 1. Find a client where: + // 1. the token provided is the revert token, + // 2. the token hasn't expired yet. + // + const REVERT_NOT_FOUND = 'BRIDGE: NO REVERT'; + var query = { + PreviousEMailValidationToken: token, + PreviousEMailValidationTokenExpiry: { + $gt: new Date() + } + }; + var options = { + comment: 'webconsole:revertChangedEmail' + }; + + let clientP = Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + query, + options, + false // Don't suppress errors + ).then((client) => client ? client : Q.reject(REVERT_NOT_FOUND)); + + // + // Step 2. If we find the client to be reverted, send an email to both address. + // We want to make sure this at least sends ok, as this may be + // the only warning someone gets of an attempt to take over the account. + // + const CANT_SEND_EMAIL = 'Failed to send email change email'; + let emailP = clientP.then(function(client) { + debug(' - sending email changed email'); + const revertingToEmail = client.PreviousEmail; + const revertingFromEmail = client.ClientName; + return mailer.sendEmailRevertedEmails( + revertingToEmail, + revertingFromEmail, + '', //Mode: always send + 'webconsole.revertEmail' + ).catch(function(error) { + return Q.reject(CANT_SEND_EMAIL); + }); + }); + + // + // Step 3. Revert the email addresses etc. in the Client object in the database + // NOTE: we don't require re-validation of the revert email address as + // (a) we are reverting to a previously used email, and + // (b) being able to do the revert requires a token from that email address + // + const BRIDGE_UPDATE_FAILED = 'Bridge: Update Failed'; + let updateP = Q.all([clientP, emailP]).then(function(results) { + debug(' - updating database'); + const client = results[0]; + + var query = { + ClientID: client.ClientID + }; + var update = { + $set: { + ClientName: client.PreviousEmail, + EMailValidationToken: '', + EMailValidationTokenExpiry: '' + }, + $unset: { + PreviousEmail: '', + PreviousEMailValidationToken: '', + PreviousEMailValidationTokenExpiry: '' + }, + $bit: { + // Set the email verified flag as the original email must have been verified + ClientStatus: {or: utils.ClientEmailVerifiedMask} + }, + $inc: { + LastVersion: 1 + }, + $currentDate: { + LastUpdate: true + } + }; + var options = { + upsert: false, + returnOriginal: false + }; + + return Q.ninvoke( + mainDB.collectionClient, + 'findOneAndUpdate', + query, + update, + options + ).then(function(result) { + if (result.ok !== 1 || !result.value) { + return Q.reject(BRIDGE_UPDATE_FAILED); + } else { + return result.value; + } + }); + }); + + // + // Step 4. Destroy the session so there can't be any accidental use + // of the old session and they'll have to log in again. + // + var resetSessionsP = updateP.then((client) => resetSessions(req, client)); + + // + // Step 5. Wait for all the promises then return the result. + // Note: we dpn't wait for the session reset promise as there's + // nothing we can do if it fails. + // + Q.all([clientP, emailP, updateP]) + .then(function(results) { + debug(' - email revert complete'); + res.status(httpStatus.OK).json(); + }) + .catch(function(error) { + debug(' - error updating email', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 30901, 'Database Offline', true + ], + [ + REVERT_NOT_FOUND, + httpStatus.BAD_REQUEST, 30902, 'Invalid revert token' + ], + [ + CANT_SEND_EMAIL, + httpStatus.BAD_GATEWAY, 30903, 'Unable to send e-mail.' + ], + [ + BRIDGE_UPDATE_FAILED, + httpStatus.BAD_REQUEST, 30904, 'Unable to revert email addresses' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + + }) + .done(); +} + +/** + * Function to get the User information from the current user + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getUser(req, res) { + // + // Get the current user's id from the session + // + var userId = req.session.data.client; + + // + // Build the query. The limits are: + // - Current user must be the owner (for security, to protect + // against Insecure Direct Object References). + // + var query = { + _id: mongodb.ObjectID(userId) + }; + + // + // Define the projection based on the Swagger definition + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + false // we don't want the _id + ); + + // + // Build the options to encapsulate the projection + // + var options = { + fields: projection, + comment: 'WebConsole:getUser' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionClient, query, options, false, + function(err, item) { + if (err) { + debug('- failed to get User', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30201, + info: 'Database offline' + }); + } else if (item === null) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30202, + info: 'Not found' + }); + } else { + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, item); + + res.status(httpStatus.OK).json(item); + } + }); +} + +/** + * Function to get the KYC information from the user + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getKYC(req, res) { + // + // Get the current user's email from the session + // + var clientID = req.session.data.clientID; + + // + // Build the query. The limits are: + // - Current user must be the owner (for security, to protect + // against Insecure Direct Object References). + // + var query = { + ClientID: clientID + }; + + // + // Define the projection based on the Swagger definition + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + false, // we don't want the _id + 'KYC' // Fields comes from the KYC subdocument + ); + + // + // Build the options to encapsulate the projection + // + var options = { + fields: projection, + comment: 'WebConsole:getKYC' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionClient, query, options, false, + function(err, item) { + if (err) { + debug('- failed to get KYC', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30201, + info: 'Database offline' + }); + } else if (item === null || !_.isArray(item.KYC)) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30202, + info: 'Not found' + }); + } else { + // + // The kyc information is an array of subdocuments. We only + // want the first one. + // + var kyc = item.KYC[0]; + anon.anonymiseKYC(kyc); + + // + // Set a default ResidentialAddressID if none in present + // + if (!kyc.hasOwnProperty('ResidentialAddressID')) { + kyc.ResidentialAddressID = null; + } + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, kyc); + + res.status(httpStatus.OK).json(kyc); + } + }); +} + +/** + * Update the KYC information from the user. + * For security reasons the date of birth must always match (unless there is + * no value for the date of birth). + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * + * @return {Promise} - promise for the result of the update + */ +function updateKYC(req, res) { + // + // Get the current user's email from the session + // + const clientID = req.session.data.clientID; + const updates = req.swagger.params.body.value; + + // + // To allow the empty string to be returned for unset Gender, we need to + // allow `""` in the enum of valid values. However, we never want to let + // someone //set// the gender to "", so we prevent it here. + // + const INVALID_GENDER = 'Bridge: Invalid Gender'; + let genderP = Q.resolve(); + if (updates.Gender === '') { + genderP = Q.reject(INVALID_GENDER); + } + + var clientP = genderP.then(() => references.getClient(clientID)); + var setP = clientP.then((client) => { + return clientUtils.setKyc(client, updates); + }); + + return Q.all([genderP, clientP, setP]) + .then((results) => { + const setResult = results[2]; + // + // We may have warnings to respond with + // + const responses = [ + [ + clientUtils.SETKYC_RESPONSES.OK, + httpStatus.OK, 10059, 'KYC details updated.' + ], + [ + clientUtils.SETKYC_RESPONSES.WARNING_REFER, + httpStatus.OK, 10079, 'Additional information required.' + ], + [ + clientUtils.SETKYC_RESPONSES.WARNING_INTERNAL_CHECKS, + httpStatus.OK, 10080, 'Additional internal checks required.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, setResult); + }) + .catch((error) => { + debug(' - error updating KYC', error); + const responses = [ + [ + 'MongoError', + httpStatus.BAD_GATEWAY, 423, 'Database Offline', true + ], + [ + references.ERRORS.INVALID_ADDRESS, + httpStatus.BAD_REQUEST, 532, 'Invalid Address', true + ], + [ + references.ERRORS.INVALID_CLIENT, + httpStatus.UNAUTHORIZED, 534, 'Client Not Found', true + ], + [ + diligence.ERRORS.VERIFICATION_FAILED, + httpStatus.BAD_REQUEST, 533, 'Unable to verify id', true + ], + [ + clientUtils.SETKYC_ERRORS.DOB_MISMATCH, + httpStatus.NOT_FOUND, 426, 'Date of birth mismatch' + ], + [ + clientUtils.SETKYC_ERRORS.UPDATE_FAILED, + httpStatus.UNAUTHORIZED, 534, 'Client not found during update' + ], + [ + clientUtils.SETKYC_ERRORS.INVALID_PARAMETERS, + httpStatus.BAD_REQUEST, 535, 'Invalid paramters' + ], + [ + INVALID_GENDER, + httpStatus.BAD_REQUEST, 30203, 'Invalid Gender. Must not be an empty string.' + ] + ]; + const responseHandler = new responsesUtils.ErrorResponses(responses); + responseHandler.respond(res, error); + }) + .done(); +} + +/** + * Function to get the Merchant information from the user + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function getMerchant(req, res) { + // + // Get the current user's details from the session + // + var clientID = req.session.data.clientID; + + // + // Build the query. The limits are: + // - Current user must be the owner (for security, to protect + // against Insecure Direct Object References). + // + var query = { + ClientID: clientID + }; + + // + // Define the projection based on the Swagger definition + // + var projection = swaggerUtils.swaggerToMongoProjection( + req.swagger.operation, + false, // we don't want the _id + 'Merchant' // Fields comes from the Merchant subdocument + ); + + // + // Build the options to encapsulate the projection + // + var options = { + fields: projection, + comment: 'WebConsole:getMerchant' // For profiler logs use + }; + + // + // Make the request + // + mainDB.findOneObject(mainDB.collectionClient, query, options, false, + function(err, item) { + if (err) { + debug('- failed to get Merchant', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30301, + info: 'Database offline' + }); + } else if (item === null || !_.isArray(item.Merchant)) { + // + // Nothing found + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30302, + info: 'Not found' + }); + } else { + // + // The merchant information is an array of subdocuments. We only + // want the first one. + // + var merchant = item.Merchant[0]; + + // + // Null any nullable fields + // + swaggerUtils.getAndApplyNullableFields(req.swagger.operation, merchant); + + res.status(httpStatus.OK).json(merchant); + } + }); +} + +/** + * Update the Merchant information from the user. + * This is only available to clients who are already enabled as merchants + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function updateMerchant(req, res) { + // + // Check the client is a merchant + // + if (!req.session.data.isMerchant) { + res.status(httpStatus.FORBIDDEN).json({ + code: 30303, + info: 'Client is not a merchant.' + }); + return; + } + + // + // Get the current user's details from the session + // + var clientID = req.session.data.clientID; + var updates = req.swagger.params.body.value; + + // + // Check we have all the required fields (not as nulls) + // + var required = req.swagger.operation.parameters[0].schema.required; + for (var i = 0; i < required.length; ++i) { + if (!_.isString(updates[required[i]])) { + res.status(httpStatus.BAD_REQUEST).json({ + code: 30304, + info: 'missing required field' + }); + return; + } + } + + // + // Check if we are allowed to set a VAT number, and fail if one has been sent + // + if ( + updates.VATNo && + !featureFlags.isEnabled(VAT_FLAG, req.session.data) + ) { + res.status(httpStatus.BAD_REQUEST).json({ + code: 30307, + info: 'Feature not enabled' + }); + return; + } + + // + // Build the query. The limits are: + // - Current user must be the owner (for security, to protect + // against Insecure Direct Object References). + // + const query = { + ClientID: clientID + }; + + // + // Build the update. This is slightly involved because the Merchant is + // an array of subdocuments, but we only want to deal with the first one. + // + var update = { + $inc: {LastVersion: 1}, + $set: { + LastUpdate: new Date(), + + // Required fields + 'Merchant.0.CompanyName': updates.CompanyName, + 'Merchant.0.CompanyAlias': updates.CompanyAlias, + + // Optional fields so set null = '' (i.e. clear if not sent) + 'Merchant.0.VATNo': updates.VATNo || '', + 'Merchant.0.CompanySubName': updates.CompanySubName || '' + } + }; + + // + // Build the options + // + var options = { + upsert: false // Don't upsert if not found + }; + + // + // Make the request + // + mainDB.updateObject(mainDB.collectionClient, query, update, options, false, + function(err, results) { + if (err) { + debug('- failed to update Merchant', err); + res.status(httpStatus.BAD_GATEWAY).json({ + code: 30305, + info: 'Database offline' + }); + } else if (results.result.n === 0) { + // + // Nothing found - most likely a date of birth mismatch + // + res.status(httpStatus.NOT_FOUND).json({ + code: 30306, + info: 'Client not found' + }); + } else { + // All good + res.status(httpStatus.OK).json(); + } + }); +} + +/** + * Revert web and device sessions to ensure all uses of the system have to + * log in again. + * + * @param {Object} req - the express request + * @param {Object} client - the current client object + * @returns {Promise} - a promise for the result of updating the session + */ +function resetSessions(req, client) { + var resetWebSessionP = Q.ninvoke(req.session, 'destroy'); + + const query = { + ClientID: client.ClientID + }; + const update = { + $set: { + SessionToken: '', + SessionTokenExpiry: new Date(0), // Jan 1st 1970 + CurrentHMAC: '', + PendingHMAC: utils.randomCode(utils.lowerCaseHex, (config.HMACBytes * 2)) + } + }; + const options = { + upsert: false + }; + var resetDeviceSessionP = Q.ninvoke( + mainDB.collectionDevice, + 'updateMany', + query, + update, + options + ); + + return Q.all([resetWebSessionP, resetDeviceSessionP]); +} + +/** + * Encodes the password to the hash that is stored in the database. This is + * a 2 step process for the web-api: + * 1: run a sha-256 hash on the password (to match what the apps do internally + * 2: use the hashUtils to generate the full hash of this sha-256 hash + * + * @param {string} password - the password to hash + * + * @returns {promise} - a promise that resolves the hashed value and salt + */ +function encodePassword(password) { + return apiUtil.encodePassword(password) + .catch(function(error) { + debug('---- hashUtil failed: ', error); + // + // Turn any errors into the expected format. + // These are all internal server errors relating to being unable + // to generate hashes, salts, etc. (and are very unlikely) + // + return promiseUtil.returnChainedError( + error, + httpStatus.INTERNAL_SERVER_ERROR, + 413, + 'Encryption error' + ); + }); +} + +/** + * Adds the user into the client database + * + * @param {String} email - The email address of the client + * @param {String} operator - The account operator (usually 'Comcarde') + * @param {String} passwordInfo - The password hash and salt + * + * @returns {Promise} - a promise for the result of adding to the db + */ +function addToDb(email, operator, passwordInfo) { + debug('- addToDb [%s] [%s]', email, operator); + + // + // Create a default formatted client + // + var client = new Client(email, passwordInfo.hash, passwordInfo.salt, operator); + + // + // And try and insert it + // - success means we created the user + // - failure means something went wrong (already in the db, db off-line, + // etc.) + // + // Return a promise that will take over from the one we have + // + return Q.ninvoke( + mainDB.collectionClient, + 'insert', + client + ).then(function(result) { + return result.ops; + }); +} + +/** + * Sends the welcome email to the client. + * + * @param {String} caller - The caller for logging purposes + * @param {Client[]} newClient - The newly added client. Takes an array for easier calling + * + * @returns {Promise} - A promise for the result of sending the email + */ +function sendWelcomeEmail(caller, newClient) { + var mode = ''; // Never disable + return mailer.sendWelcomeEmail(newClient[0], mode, caller); +} + +/** + * Reports successful completion of adding a new client (i.e. 201 Created) + * + * @param {object} res - Express response object + */ +function returnSuccess(res) { + // + // All good + // + debug('- user added successfully'); + res + .status(httpStatus.CREATED) + .type('application/json') + .end(); +} + +/** + * Reports a failure to add a user + * + * @param {Object} err - the error object + * + * @return {Promise} - a rejected promise with the error info + */ +function failedAddUser(err) { + debug(' - failed to add user'); + if (promiseUtil.hasChainedError(err)) { + // Resend previous error + return promiseUtil.resendChainedError(err); + } else if (err && + err.name && + err.name === 'MongoError' && + err.code && + err.code === 11000 + ) { + // Error code 11000 is MongoDB can't insert duplicate + debug(' -- reason: Email address already in use'); + + return promiseUtil.returnChainedError( + err, + httpStatus.CONFLICT, + 10, + 'Email address already in use' + ); + } else { + debug(' -- reason: ', err); + + return promiseUtil.returnChainedError( + err, + httpStatus.SERVICE_UNAVAILABLE, + 6, + 'Database temporarily off line' + ); + } +} + +/** + * Returns the failure code to the caller. This failure code can come + * from a previous error handler, or we will add an 'unknown' error. + * + * @param {Object} err - the error object + * + * @return {Promise} - a rejected promise with the rejected error + */ +function failedSendEmail(err) { + if (promiseUtil.hasChainedError(err)) { + // Ignore previous error + return promiseUtil.resendChainedError(err); + } else { + // + // Couldn't send email + // + debug('- failed to send email: ', err); + return promiseUtil.returnChainedError( + err, + httpStatus.SERVICE_UNAVAILABLE, + 11, + 'Failed to send email' + ); + } +} diff --git a/node_server/swagger_api/controllers/api_utils_controller.js b/node_server/swagger_api/controllers/api_utils_controller.js new file mode 100644 index 0000000..b4a3024 --- /dev/null +++ b/node_server/swagger_api/controllers/api_utils_controller.js @@ -0,0 +1,5 @@ +const versionsController = require('./api_utils_controllers/api_versions_controller'); + +module.exports = { + getVersions: versionsController.getVersions +}; diff --git a/node_server/swagger_api/controllers/api_utils_controllers/api_versions_controller.js b/node_server/swagger_api/controllers/api_utils_controllers/api_versions_controller.js new file mode 100644 index 0000000..9df7a2b --- /dev/null +++ b/node_server/swagger_api/controllers/api_utils_controllers/api_versions_controller.js @@ -0,0 +1,25 @@ +/** + * Controller to retreive the server version + */ +'use strict'; +const httpStatus = require('http-status-codes'); + +const config = require(global.configFile); + +module.exports = { + getVersions +}; + +/** + * Gets the version of the server with the commitHash that the server was built from appended to the end. + * + * @param {!object} req - Request object. + * @param {!object} res - Response object for returning information. + */ +function getVersions(req, res) { + const serverVersions = { + ServerVersion: config.CCServerVersion + }; + + res.status(httpStatus.OK).json(serverVersions); +} diff --git a/node_server/swagger_api/controllers/tests/api_recovery_controller.spec.js b/node_server/swagger_api/controllers/tests/api_recovery_controller.spec.js new file mode 100644 index 0000000..5e68781 --- /dev/null +++ b/node_server/swagger_api/controllers/tests/api_recovery_controller.spec.js @@ -0,0 +1,328 @@ +/* globals describe, beforeEach, afterEach, it */ +/** + * Unit testing file for the worldpay_acquirer + */ +'use strict'; +const testGlobals = require('../../../tools/test/testGlobals.js'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const recoveryController = rewire('../api_recovery_controller.js'); + +const expect = chai.expect; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +// +// Array of test cases for testing comparison of answers to expected values. +// The anwers are in [type, expected, actual] order +// +const kbaTestCases = [ + { + name: '1 of each type (exact match)', + valid: true, + data: [ + ['postcode', 'KA9 2HD', 'KA9 2HD'], + ['card', '000', '000'], + ['transactions', 0, '0'], + ['device', '+4477777777', '+4477777777'], + ['dob', '1970-01-01', '1970-01-01'] + ] + }, + { + name: 'postcode with space differences 1', + valid: true, + data: [ + ['postcode', 'KA9 2HD', 'KA92HD'] + ] + }, + { + name: 'postcode with space differences 2', + valid: true, + data: [ + ['postcode', 'KA92HD', 'KA9 2HD'] + ] + }, + { + name: 'postcode with case differences 1', + valid: true, + data: [ + ['postcode', 'KA9 2HD', 'ka9 2hd'] + ] + }, + { + name: 'postcode with case differences 2', + valid: true, + data: [ + ['postcode', 'ka9 2hd', 'KA9 2HD'] + ] + }, + { + name: 'postcode with many case and space differences', + valid: true, + data: [ + ['postcode', 'k A 9 2 h D', 'Ka92Hd'] + ] + }, + { + name: 'postcode with missing last char', + valid: false, + data: [ + ['postcode', 'KA9 2HD', 'KA9 2H'] + ] + }, + { + name: 'postcode with homograph chars (the 2nd K is 0x039A GREEK CAPIAL LETTER KAPPA!)', + valid: false, + data: [ + ['postcode', 'KA9 2HD', 'ΚA9 2HD'] + ] + }, + { + name: 'card with a different value', + valid: false, + data: [ + ['card', '123', '124'] + ] + }, + { + name: 'card with a Number rather than a string', + valid: false, + data: [ + ['card', '123', 123] + ] + }, + { + name: 'card with missing leading 0 ("012" != "12")', + valid: false, + data: [ + ['card', '012', '12'] + ] + }, + { + name: '1 expected transaction', + valid: true, + data: [ + ['transactions', 1, '1'] + ] + }, + { + name: '2 expected transactions (still bucket 1-2)', + valid: true, + data: [ + ['transactions', 2, '1'] + ] + }, + { + name: '3 expected transactions', + valid: true, + data: [ + ['transactions', 3, '3'] + ] + }, + { + name: '4 expected transactions (still bucket 3-4)', + valid: true, + data: [ + ['transactions', 4, '3'] + ] + }, + { + name: '5 expected transactions', + valid: true, + data: [ + ['transactions', 5, '5'] + ] + }, + { + name: '6 expected transactions (still bucket 5+)', + valid: true, + data: [ + ['transactions', 6, '5'] + ] + }, + { + name: '100 expected transactions (still bucket 5+)', + valid: true, + data: [ + ['transactions', 100, '5'] + ] + }, + { + name: 'actual transactions not a valid bucket (2)', + valid: false, + data: [ + ['transactions', 2, '2'] + ] + }, + { + name: 'actual transactions not a valid bucket (4)', + valid: false, + data: [ + ['transactions', 4, '4'] + ] + }, + { + name: 'actual transactions not a valid bucket (6)', + valid: false, + data: [ + ['transactions', 6, '6'] + ] + }, + { + name: 'actual transactions not a valid bucket (99)', + valid: false, + data: [ + ['transactions', 99, '99'] + ] + }, + { + name: 'actual transactions don\'t match', + valid: false, + data: [ + ['transactions', 1, '0'] + ] + }, + { + name: 'device number doesn\'t match', + valid: false, + data: [ + ['device', '+447700900123', '+447700900124'] + ] + }, + { + name: 'non-international format number', + valid: false, + data: [ + ['device', '+447700900123', '07700900123'] + ] + }, + { + name: 'date of birth in ISO 8601 with a time', + valid: true, + data: [ + ['dob', '1970-01-02', '1970-01-02T01:23:45'] + ] + }, + { + name: 'date of birth in ISO 8601 without a time', + valid: true, + data: [ + ['dob', '1970-02-03', '1970-02-03'] + ] + }, + { + name: 'wrong day only', + valid: false, + data: [ + ['dob', '1970-01-01', '1970-01-02'] + ] + }, + { + name: 'wrong month only', + valid: false, + data: [ + ['dob', '1970-01-01', '1970-02-01'] + ] + }, + { + name: 'wrong year only', + valid: false, + data: [ + ['dob', '1970-01-01', '1971-01-01'] + ] + }, + { + name: 'date of birth in a non-ISO 8601 format (long)', + valid: false, + data: [ + ['dob', '1970-01-01', '1st January 1970'] + ] + }, + { + name: 'date of birth in a non-ISO 8601 format (short)', + valid: false, + data: [ + ['dob', '1970-01-01', '01/01/1970'] + ] + } +]; + +/** + * Converts the above test cases to the formats expected by the verifyAnswers(). + * + * @param {Object[]} cases - List of test cases. + * + * @returns {Object} - Object with list of expected results and answers to test with. + */ +function testCaseToVerifyAnswers(cases) { + const result = { + expected: [], + actual: [] + }; + + for (let i = 0; i < cases.length; ++i) { + const tc = cases[i]; + + result.expected.push({ + questionID: i, + questionType: tc[0], + answer: tc[1] + }); + + result.actual.push({ + questionID: i, + questionType: tc[0], + answer: tc[2] + }); + } + + return result; +} + +/** + * Unit test definitions + */ +describe('api_recovery_controller', () => { + describe('verifyAnswers', () => { + /** + * Get the private function using the `rewire`-ed controller object. + */ + const verifyAnswers = recoveryController.__get__('verifyAnswers'); + + for (let i = 0; i < kbaTestCases.length; ++i) { + /** + * Get the testcase, then the test data in the correct format. + */ + const tc = kbaTestCases[i]; + const tcConverted = testCaseToVerifyAnswers(tc.data); + + /** + * Build a meaningful name for the test. + */ + let name = tc.valid ? 'should accept ' : 'should NOT accept '; + + name += tc.name; + + /** + * Run a test for this case. + */ + it(name, () => { + const expected = expect(verifyAnswers(tcConverted.actual, tcConverted.expected)); + + if (tc.valid) { + return expected.to.eventually.be.fulfilled; + } else { + return expected.to.eventually.be.rejected; + } + }); + } + }); +}); diff --git a/node_server/swagger_api/specs/api_body_middleware.spec.js b/node_server/swagger_api/specs/api_body_middleware.spec.js new file mode 100644 index 0000000..b379ad5 --- /dev/null +++ b/node_server/swagger_api/specs/api_body_middleware.spec.js @@ -0,0 +1,147 @@ +/** + * Unit testing file for ElevateSession command + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] import/max-dependencies: ["error", {"max": 11}] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const _ = require('lodash'); +const Q = require('q'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); + +const utils = require('../../ComServe/utils.js'); + +const {MockRequest} = require('../../utils/test/mock-request'); +const {bridgeBodyParser} = require('../api_body_middleware.js'); + +/** + * Set up chai & sinon to simplify the tests + */ +const expect = chai.expect; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +/** + * Make a promise-style version of the middleware handler for easier testing + */ +const middlewareP = (req) => Q.nfcall( + bridgeBodyParser(), + req, + {} // Don't use res, so not defined +); + +/** + * Valid values + */ +const PROTOCOL = 'https'; +const PATH = '/api/v0/devices'; +const METHOD = 'get'; + +const MOCK_REQUEST_OPTIONS = { + originalUrl: PATH, + protocol: PROTOCOL, + method: METHOD +}; + +const MOCK_REQUEST_BODY_OPTIONS = { + mockBody: '{\n' + + ' "test": "value",\n' + + ' "other": "value2"\n' + + '}' +}; + +/** + * Function to create a mock `req` objects that mimics the important parts of a + * real request object. + * + * @param {Object} resolvedSwagger - The **resolved** swagger object (i.e. all refs resolved) + * @param {Object} reqOptions - The additional fields to add to the request object + * @param {Object} bodyOptions - Additional params for creating the MockRequest (provides the body) + */ +function createMockReq(resolvedSwagger, reqOptions, bodyOptions) { + const req = new MockRequest(_.cloneDeep(bodyOptions)); + _.merge(req, _.cloneDeep(reqOptions)); + return req; +} + +/** + * The tests + */ +describe('Custom body middleware', () => { + let resolvedSwagger; + let req; + + describe('with valid JSON body', () => { + beforeEach(() => { + req = createMockReq(resolvedSwagger, MOCK_REQUEST_OPTIONS, MOCK_REQUEST_BODY_OPTIONS); + }); + + it('runs and completes', () => { + return expect(middlewareP(req)).to.eventually.be.fulfilled; + }); + + it('parses the JSON and stores it in req.body', () => { + return middlewareP(req) + .then(() => { + return expect(req.body) + .to.deep.equal({ + test: 'value', + other: 'value2' + }); + }); + }); + + it('stores the raw body in req.bodyRaw', () => { + return middlewareP(req) + .then(() => { + return expect(req.bodyRaw) + .to.equal(MOCK_REQUEST_BODY_OPTIONS.mockBody); + }); + }); + }); + + describe('with no body', () => { + beforeEach(() => { + req = createMockReq(resolvedSwagger, MOCK_REQUEST_OPTIONS); + }); + + it('runs and completes', () => { + return expect(middlewareP(req)).to.eventually.be.fulfilled; + }); + + it('stores empty object in req.body', () => { + return middlewareP(req) + .then(() => { + return expect(req.body) + .to.deep.equal({}); + }); + }); + + it('leaves req.bodyRaw undefined', () => { + return middlewareP(req) + .then(() => { + return expect(req.bodyRaw) + .to.be.undefined; + }); + }); + }); + + describe('with body that\'s too big', () => { + it('runs and fails', () => { + const tooManyAs = utils.maxPacketSize - 8 + 1; // 8 other chars in valid body + const mockBody = '{"b":"' + + 'c'.repeat(tooManyAs) + + '"}'; + req = createMockReq( + resolvedSwagger, + MOCK_REQUEST_OPTIONS, { + mockBody + }); + + return expect(middlewareP(req)).to.eventually.be.rejectedWith('too large'); + }); + }); +}); diff --git a/node_server/swagger_api/specs/api_security_device.spec.js b/node_server/swagger_api/specs/api_security_device.spec.js new file mode 100644 index 0000000..bc0851e --- /dev/null +++ b/node_server/swagger_api/specs/api_security_device.spec.js @@ -0,0 +1,845 @@ +/** + * Unit testing file for ElevateSession command + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] import/max-dependencies: ["error", {"max": 13}] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const _ = require('lodash'); +const Q = require('q'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); +const JsonRefs = require('json-refs'); +const mongodb = require('mongodb'); + +const {MockRequest} = require('../../utils/test/mock-request'); +const {bridgeBodyParser} = require('../api_body_middleware.js'); + +const utils = require('../../ComServe/utils'); + +/** + * Use rewire to pull in the unit under test, and then get access to the + * private variables to stub them + */ +const apiSecurityDevice = rewire('../api_security_device.js'); + +const authStub = apiSecurityDevice.__get__('auth'); +const flagsStub = apiSecurityDevice.__get__('featureFlags'); +const referencesStub = apiSecurityDevice.__get__('references'); +const mainDBPStub = apiSecurityDevice.__get__('mainDBP'); + +/** + * Set up chai & sinon to simplify the tests + */ +const expect = chai.expect; +const sandbox = sinon.createSandbox(); + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +/** + * Make a promise-style version of the security handler for easier testing + */ +const deviceSessionP = (req, def, scopes) => Q.nfcall( + apiSecurityDevice.deviceSession, + req, + def, + scopes +); +const hmacNoSessionP = (req, def, scopes) => Q.nfcall( + apiSecurityDevice.deviceHmacNoSession, + req, + def, + scopes +); + +const COLLECTION_DEVICES = 'Mock devices collection parameter'; + +/** + * Valid values + */ +const DEVICE_TOKEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop'; +const SESSION_TOKEN = 'qrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUV'; +const DEVICE_MONGO_ID = (new mongodb.ObjectID()).toHexString(); // New random ObjectID +const CLIENT_NAME = 'a@example.com'; +const CLIENT_MONGO_ID = (new mongodb.ObjectID()).toHexString(); // New random ObjectID +const CLIENT_ID = 'A unique random value generated by us'; +const CLIENT_DISPLAY_NAME = 'Display Name'; + +const SESSION_HEADER = DEVICE_TOKEN + ':' + SESSION_TOKEN; +const HMAC_HEADER = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +const TIMESTAMP_HEADER = new Date().toISOString(); + +const PROTOCOL = 'https'; +const PATH = '/api/v0/devices'; +const EXPECTED_FULL_URL = 'https://unittest.example.com' + PATH; +const METHOD = 'get'; + +const SESSION_ID = 'A session id as-if generated by express-session middleware'; + +const DB_FEATURE_FLAGS = ['unit-test']; +const DB_DEVICE = { + ClientID: CLIENT_ID, + DeviceToken: DEVICE_TOKEN, + DeviceStatus: utils.DeviceFullyRegistered +}; +const DB_CLIENT = { + _id: CLIENT_MONGO_ID, + ClientName: CLIENT_NAME, + ClientID: CLIENT_ID, + DisplayName: CLIENT_DISPLAY_NAME, + FeatureFlags: DB_FEATURE_FLAGS, + ClientStatus: utils.ClientEmailVerifiedMask +}; + +const MOCK_SWAGGER_PATHNAME = '/test-api-security-device'; +const MOCK_SWAGGER_FEATURE_FLAG = 'unit-test'; +const MOCK_SWAGGER_DEFINITION = { + post: { + summary: 'Just a test', + description: 'Just a test', + 'x-feature-flag': MOCK_SWAGGER_FEATURE_FLAG, + responses: { + 200: { + description: 'Success' + } + } + } +}; + +/** + * Mock request for requests that use the standard security model + */ +const MOCK_REQUEST_OPTIONS = { + headers: { + 'x-bridge-device-session': SESSION_HEADER, + 'x-bridge-hmac': HMAC_HEADER, + 'x-bridge-timestamp': TIMESTAMP_HEADER + }, + originalUrl: PATH, + protocol: PROTOCOL, + method: METHOD, + sessionID: SESSION_ID, + session: {} // Empty session as-if created by express-session +}; + +const MOCK_REQUEST_BODY = '{\n' + + ' "test": "value",\n' + + ' "other": "value2"\n' + + '}'; +const MOCK_REQUEST_BODY_OPTIONS = { + mockBody: MOCK_REQUEST_BODY +}; + +/** + * Mock request for requests that use the "no session" security model + */ +const SWAGGER_PATH_LOGIN = '/devices/{objectId}/login'; +const PATH_LOGIN = '/api/v0/devices/' + DEVICE_MONGO_ID + '/login'; +const EXPECTED_FULL_URL_LOGIN = 'https://unittest.example.com' + PATH_LOGIN; +const METHOD_LOGIN = 'POST'; + +const MOCK_LOGIN_REQUEST_OPTIONS = { + headers: { + 'x-bridge-hmac': HMAC_HEADER, + 'x-bridge-timestamp': TIMESTAMP_HEADER + }, + originalUrl: PATH_LOGIN, + protocol: PROTOCOL, + method: METHOD_LOGIN, + sessionID: SESSION_ID, + session: {} // Empty session as-if created by express-session +}; + +const MOCK_LOGIN_REQUEST_BODY = '{\n' + + ' "ClientName": "' + CLIENT_NAME + '"\n' + + '}'; + +const MOCK_LOGIN_REQUEST_BODY_OPTIONS = { + mockBody: MOCK_LOGIN_REQUEST_BODY +}; + +/** + * Function to create a mock `req` objects that mimics the important parts of a + * real request object. + * + * @param {Object} resolvedSwagger - The **resolved** swagger object (i.e. all refs resolved) + * @param {Object} reqOptions - The additional fields to add to the request object + * @param {Object} bodyOptions - Additional params for creating the MockRequest (provides the body) + */ +function createMockReq(resolvedSwagger, reqOptions, bodyOptions) { + const req = new MockRequest(_.cloneDeep(bodyOptions)); + _.merge(req, _.cloneDeep(reqOptions)); + return req; +} + +/** + * The tests + */ +describe('Device security validation', () => { + let resolvedSwagger; + let req; + const def = {}; + + /** + * Before we run any tests we need to resolve all the references within + * the swagger specification. + */ + before(() => { + /** + * Set some values for the collections so we can differentiate them + */ + mainDBPStub.mainDB._collectionDevice = mainDBPStub.mainDB.collectionDevice; + mainDBPStub.mainDB.collectionDevice = COLLECTION_DEVICES; + + /** + * Load the swagger files and merge them back into a single file + */ + return JsonRefs + .resolveRefsAt(require.resolve('../api_swagger_def.json')) + .then((swagger) => { + /** + * Add our test path to the swagger + */ + resolvedSwagger = swagger.resolved; + resolvedSwagger.paths[MOCK_SWAGGER_PATHNAME] = MOCK_SWAGGER_DEFINITION; + + // + // Add them to the default options + // + _.assign(MOCK_REQUEST_OPTIONS, { + swagger: { + swaggerObject: resolvedSwagger, + operation: resolvedSwagger.paths[MOCK_SWAGGER_PATHNAME].post + } + }); + + return resolvedSwagger; + }); + }); + + after(() => { + /** + * Set the collections back + */ + mainDBPStub.mainDB.collectionDevice = mainDBPStub.mainDB._collectionDevice; + delete mainDBPStub.mainDB._collectionDevice; + }); + + describe('device_session security', () => { + /** + * Before each test, stub the auth functions we will be calling. + */ + beforeEach((done) => { + req = createMockReq(resolvedSwagger, MOCK_REQUEST_OPTIONS, MOCK_REQUEST_BODY_OPTIONS); + + sandbox.stub(authStub, 'validateCurrentSession').resolves([DB_DEVICE, DB_CLIENT]); + sandbox.stub(authStub, 'checkHMAC').resolves(); + sandbox.spy(flagsStub, 'isEnabled'); + + // + // Run the mock request through the middleware to get the parsed and raw bodies + // + bridgeBodyParser()(req, {}, done); + }); + + /** + * After each test we reset the sanbox to reset all stubs etc. + */ + afterEach(() => { + sandbox.restore(); + }); + + describe('With validly formatted params that are correct', () => { + it('checks the device and session tokens', () => { + return deviceSessionP(req, def, SESSION_HEADER).then(() => { + return expect(authStub.validateCurrentSession) + .to.have.been.calledOnce + .calledWith( + DEVICE_TOKEN, + SESSION_TOKEN + ); + }); + }); + + it('checks the HMAC', () => { + return deviceSessionP(req, def, SESSION_HEADER).then(() => { + return expect(authStub.checkHMAC) + .to.have.been.calledOnce + .calledWith( + DB_DEVICE, + { + address: EXPECTED_FULL_URL, + method: METHOD, + body: MOCK_REQUEST_BODY + DEVICE_TOKEN + ':' + SESSION_TOKEN, + ClientName: CLIENT_NAME, + timestamp: TIMESTAMP_HEADER, + hmac: HMAC_HEADER + } + ); + }); + }); + + it('checks the FeatureFlags if they are required for the request', () => { + return deviceSessionP(req, def, SESSION_HEADER).then(() => { + return expect(flagsStub.isEnabled) + .to.have.been.calledOnce + .calledWith( + MOCK_SWAGGER_FEATURE_FLAG, + DB_CLIENT + ); + }); + }); + + it('doesn\'t check the FeatureFlags if they are NOT required for the request', () => { + delete req.swagger.operation['x-feature-flag']; + return deviceSessionP(req, def, SESSION_HEADER).then(() => { + return expect(flagsStub.isEnabled) + .to.not.have.been.called; + }); + }); + + it('stores web session data + the client (as clientObj) and device (as deviceObj) in req.session.data for controller to use', () => { + return deviceSessionP(req, def, SESSION_HEADER).then(() => { + return expect(req.session.data) + .to.deep.equal({ + // + // Existing session details for old web console requests + // + client: CLIENT_MONGO_ID, + clientID: CLIENT_ID, + displayName: CLIENT_DISPLAY_NAME, + email: CLIENT_NAME, + isMerchant: false, + isVATRegistered: false, + FeatureFlags: DB_FEATURE_FLAGS, + + // + // New sessiond details for App APIs copied across + // + clientObj: DB_CLIENT, + deviceObj: DB_DEVICE, + isDeviceSession: true + }); + }); + }); + + it('clears req.sessionID so express-session doesn\'t persist the session', () => { + return deviceSessionP(req, def, SESSION_HEADER).then(() => { + return expect(req.sessionID) + .to.be.null; + }); + }); + + it('passes the security tests', () => { + return expect(deviceSessionP(req, def, SESSION_HEADER)) + .to.eventually.be.fulfilled; + }); + }); + + describe('With validly formatted params that are wrong', () => { + it('rejects when required feature flag isn\'t enabled', () => { + const NO_FEATURE_FLAG_CLIENT = { + ClientName: CLIENT_NAME + }; + authStub.validateCurrentSession.resolves([DB_DEVICE, NO_FEATURE_FLAG_CLIENT]); + + return expect(deviceSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when validating current session fails', () => { + authStub.validateCurrentSession.rejects(); + return expect(deviceSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when checking HMAC fails', () => { + authStub.checkHMAC.rejects(); + return expect(deviceSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('leaves req.sessionID alone when device session verifcation fails', () => { + authStub.checkHMAC.rejects(); + return deviceSessionP(req, def, SESSION_HEADER).catch(() => { + return expect(req.sessionID) + .to.equal(SESSION_ID); + }); + }); + }); + + describe('With invalidly formatted params', () => { + describe('Rejects when x-bridge-session-device is the wrong format: ', () => { + it('missing entirely', () => { + return expect(deviceSessionP(req, def, undefined)).to + .eventually.be.rejected; + }); + + it('device token too short', () => { + const token = DEVICE_TOKEN.slice(0, -1) + ':' + SESSION_TOKEN; + return expect(deviceSessionP(req, def, token)).to + .eventually.be.rejected; + }); + + it('device token too long', () => { + const token = DEVICE_TOKEN + 'a:' + SESSION_TOKEN; + return expect(deviceSessionP(req, def, token)).to + .eventually.be.rejected; + }); + + it('device token invalid char', () => { + const token = DEVICE_TOKEN.slice(0, -1) + '!:' + SESSION_TOKEN; + return expect(deviceSessionP(req, def, token)).to + .eventually.be.rejected; + }); + + it('session token too short', () => { + const token = DEVICE_TOKEN + ':' + SESSION_TOKEN.slice(0, -1); + return expect(deviceSessionP(req, def, token)).to + .eventually.be.rejected; + }); + + it('session token too long', () => { + const token = DEVICE_TOKEN + ':b' + SESSION_TOKEN; + return expect(deviceSessionP(req, def, token)).to + .eventually.be.rejected; + }); + + it('session token invalid char', () => { + const token = DEVICE_TOKEN + ':?' + SESSION_TOKEN.slice(0, -1); + return expect(deviceSessionP(req, def, token)).to + .eventually.be.rejected; + }); + + it('wrong character between tokens', () => { + const token = DEVICE_TOKEN + ';' + SESSION_TOKEN; + return expect(deviceSessionP(req, def, token)).to + .eventually.be.rejected; + }); + }); + + describe('Rejects when x-bridge-hmac is the wrong format: ', () => { + it('missing entirely', () => { + delete req.headers['x-bridge-hmac']; + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('too short', () => { + req.headers['x-bridge-hmac'] = HMAC_HEADER.slice(0, -1); + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('too long', () => { + req.headers['x-bridge-hmac'] = HMAC_HEADER + 'a'; + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('invalid char', () => { + req.headers['x-bridge-hmac'] = HMAC_HEADER.slice(0, -1) + '!'; + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + }); + + describe('Rejects when x-bridge-timestamp is the wrong format: ', () => { + it('missing entirely', () => { + delete req.headers['x-bridge-timestamp']; + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('not a string', () => { + req.headers['x-bridge-timestamp'] = Date.now(); + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('only has date', () => { + req.headers['x-bridge-timestamp'] = new Date().toDateString(); + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('only has time', () => { + req.headers['x-bridge-timestamp'] = new Date().toTimeString(); + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + }); + }); + }); + + describe('device_hmac_nosession security', () => { + /** + * Before each test, stub the auth functions we will be calling. + */ + beforeEach((done) => { + // + // Setup the swagger details as-if parsed from the request by the + // swagger middleware + // + _.assign(MOCK_LOGIN_REQUEST_OPTIONS, { + swagger: { + swaggerObject: resolvedSwagger, + operation: resolvedSwagger.paths[SWAGGER_PATH_LOGIN].post, + params: { + objectId: { + value: DEVICE_MONGO_ID + }, + body: { + value: { + ClientName: CLIENT_NAME + } + } + } + } + }); + + req = createMockReq( + resolvedSwagger, + MOCK_LOGIN_REQUEST_OPTIONS, + MOCK_LOGIN_REQUEST_BODY_OPTIONS + ); + + sandbox.stub(authStub, 'checkHMAC').resolves(); + sandbox.stub(referencesStub, 'getClientByEmail').resolves(DB_CLIENT); + sandbox.stub(mainDBPStub, 'findOneObject').resolves(DB_DEVICE); + + sandbox.spy(flagsStub, 'isEnabled'); + sandbox.spy(authStub, 'checkClientStatus'); + sandbox.spy(authStub, 'checkDeviceStatus'); + + // + // Run the mock request through the middleware to get the parsed and raw bodies + // + bridgeBodyParser()(req, {}, done); + }); + + /** + * After each test we reset the sanbox to reset all stubs etc. + */ + afterEach(() => { + sandbox.restore(); + }); + + describe('With validly formatted params that are correct', () => { + it('gets the client based on the ClientName in the body', () => { + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(referencesStub.getClientByEmail) + .to.have.been.calledOnce + .calledWith(CLIENT_NAME); + }); + }); + + it('gets the device based on the objectID if it is owned by the correct client', () => { + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(mainDBPStub.findOneObject) + .to.have.been.calledOnce + .calledWith( + mainDBPStub.mainDB.collectionDevice, + { + _id: mongodb.ObjectID(DEVICE_MONGO_ID), + ClientID: CLIENT_ID + } + ); + }); + }); + + it('checks the client is in a valid state', () => { + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(authStub.checkClientStatus) + .to.have.been.calledOnce + .calledWith(utils.ClientEmailVerifiedMask) + .returned(null); // Null for no errors + }); + }); + + it('checks the device is in a valid state', () => { + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(authStub.checkDeviceStatus) + .to.have.been.calledOnce + .calledWith(utils.DeviceFullyRegistered) + .returned(null); // Null for no errors + }); + }); + + it('checks the HMAC, with the function name set to Login1.process', () => { + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(authStub.checkHMAC) + .to.have.been.calledOnce + .calledWith( + DB_DEVICE, + { + address: EXPECTED_FULL_URL_LOGIN, + method: METHOD_LOGIN, + body: MOCK_LOGIN_REQUEST_BODY, + ClientName: CLIENT_NAME, + timestamp: TIMESTAMP_HEADER, + hmac: HMAC_HEADER + }, + 'Login1.process' // Renamed to Login1.process to match expectations + ); + }); + }); + + it('checks the FeatureFlags if they are required for the request', () => { + req.swagger.operation['x-feature-flag'] = MOCK_SWAGGER_FEATURE_FLAG; + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(flagsStub.isEnabled) + .to.have.been.calledOnce + .calledWith( + MOCK_SWAGGER_FEATURE_FLAG, + DB_CLIENT + ); + }); + }); + + it('doesn\'t check the FeatureFlags if they are NOT required for the request', () => { + delete req.swagger.operation['x-feature-flag']; + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(flagsStub.isEnabled) + .to.not.have.been.called; + }); + }); + + it('stores web session data + the client (as clientObj) and device (as deviceObj) in req.session.data for controller to use', () => { + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(req.session.data) + .to.deep.equal({ + // + // Existing session details for old web console requests + // + client: CLIENT_MONGO_ID, + clientID: CLIENT_ID, + displayName: CLIENT_DISPLAY_NAME, + email: CLIENT_NAME, + isMerchant: false, + isVATRegistered: false, + FeatureFlags: DB_FEATURE_FLAGS, + + // + // New sessiond details for App APIs copied across + // + clientObj: DB_CLIENT, + deviceObj: DB_DEVICE, + isDeviceSession: true + }); + }); + }); + + it('clears req.sessionID so express-session doesn\'t persist the session', () => { + return hmacNoSessionP(req, def, SESSION_HEADER).then(() => { + return expect(req.sessionID) + .to.be.null; + }); + }); + + it('passes the security tests', () => { + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .to.eventually.be.fulfilled; + }); + }); + + describe('With validly formatted params that are wrong', () => { + it('rejects when required feature flag isn\'t enabled', () => { + // Fake that a feature flag is required + req.swagger.operation['x-feature-flag'] = MOCK_SWAGGER_FEATURE_FLAG; + + // Return a client that doesn't have that flag + const modifiedClient = _.cloneDeep(DB_CLIENT); + modifiedClient.FeatureFlags = []; + referencesStub.getClientByEmail.resolves(modifiedClient); + + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when finding the client fails', () => { + referencesStub.getClientByEmail.rejects(); + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when finding the device fails', () => { + mainDBPStub.findOneObject.rejects(); + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when the client isn\'t verified', () => { + const modifiedClient = _.cloneDeep(DB_CLIENT); + modifiedClient.ClientStatus = 0; + referencesStub.getClientByEmail.resolves(modifiedClient); + + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when the client is barred', () => { + const modifiedClient = _.cloneDeep(DB_CLIENT); + modifiedClient.ClientStatus |= utils.ClientBarredMask; + referencesStub.getClientByEmail.resolves(modifiedClient); + + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when the device isn\'t completely registered', () => { + const modifiedDevice = _.cloneDeep(DB_DEVICE); + modifiedDevice.DeviceStatus = utils.DeviceRegister2Mask; + mainDBPStub.findOneObject.resolves(modifiedDevice); + + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when the device is suspended', () => { + const modifiedDevice = _.cloneDeep(DB_DEVICE); + modifiedDevice.DeviceStatus |= utils.DeviceSuspendedMask; + mainDBPStub.findOneObject.resolves(modifiedDevice); + + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when the device is barred', () => { + const modifiedDevice = _.cloneDeep(DB_DEVICE); + modifiedDevice.DeviceStatus |= utils.DeviceBarredMask; + mainDBPStub.findOneObject.resolves(modifiedDevice); + + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('rejects when checking HMAC fails', () => { + authStub.checkHMAC.rejects(); + return expect(hmacNoSessionP(req, def, SESSION_HEADER)) + .eventually.be.rejected; + }); + + it('leaves req.sessionID alone when device session verifcation fails', () => { + authStub.checkHMAC.rejects(); + return hmacNoSessionP(req, def, SESSION_HEADER).catch(() => { + return expect(req.sessionID) + .to.equal(SESSION_ID); + }); + }); + }); + + describe('With invalidly formatted params', () => { + describe('Rejects when x-bridge-hmac is the wrong format: ', () => { + it('missing entirely', () => { + delete req.headers['x-bridge-hmac']; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('too short', () => { + req.headers['x-bridge-hmac'] = HMAC_HEADER.slice(0, -1); + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('too long', () => { + req.headers['x-bridge-hmac'] = HMAC_HEADER + 'a'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('invalid char', () => { + req.headers['x-bridge-hmac'] = HMAC_HEADER.slice(0, -1) + '!'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + }); + + describe('Rejects when x-bridge-timestamp is the wrong format: ', () => { + it('missing entirely', () => { + delete req.headers['x-bridge-timestamp']; + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('not a string', () => { + req.headers['x-bridge-timestamp'] = Date.now(); + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('only has date', () => { + req.headers['x-bridge-timestamp'] = new Date().toDateString(); + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('only has time', () => { + req.headers['x-bridge-timestamp'] = new Date().toTimeString(); + return expect(deviceSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + }); + + describe('Rejects when objectId is the wrong format: ', () => { + it('too short', () => { + req.swagger.params.objectId.value = DEVICE_MONGO_ID.slice(0, -1); + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('too long', () => { + req.swagger.params.objectId.value = DEVICE_MONGO_ID + 'a'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('invalid char', () => { + req.swagger.params.objectId.value = DEVICE_MONGO_ID.slice(0, -1) + 'g'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + }); + + describe('Rejects when ClientName is the wrong format: ', () => { + it('too short', () => { + req.swagger.params.body.value.ClientName = 'a@b.co'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('too long', () => { + req.swagger.params.body.value.ClientName = + 'a@' + + 'b'.repeat(249) + + '.com'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('invalid char', () => { + req.swagger.params.body.value.ClientName = 'a@Bücher.example'; // No IDN support + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('missing the @', () => { + req.swagger.params.body.value.ClientName = 'example.com'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + + it('missing the tld', () => { + req.swagger.params.body.value.ClientName = 'aexample'; + return expect(hmacNoSessionP(req, def, SESSION_HEADER)).to + .eventually.be.rejected; + }); + }); + }); + }); +}); diff --git a/node_server/test/init_mocha.js b/node_server/test/init_mocha.js new file mode 100644 index 0000000..b5a4508 --- /dev/null +++ b/node_server/test/init_mocha.js @@ -0,0 +1,29 @@ +/** + * @fileOverview Initialisation file to run before mocha to allow needed setup + */ +'use strict'; +const logging = require('../utils/logging'); +const Transport = require('winston-transport'); + +/** + * Remove all configured transports e.g. so we don't try to output logs to databases + */ +const transports = logging._test.getTransports(); +const logger = logging._test.getLogger(); + +transports.forEach((transport) => { + logger.remove(transport); +}); + +/** + * Add a new, null transport to appease Winston. + */ +class NullTransport extends Transport { + // eslint-disable-next-line class-methods-use-this + log(info, callback) { + // Null transport doesn't do anything + callback(); + } +} +const nullTransport = new NullTransport(); +logger.add(nullTransport); diff --git a/node_server/test/mocha.opts b/node_server/test/mocha.opts new file mode 100644 index 0000000..21ba3e0 --- /dev/null +++ b/node_server/test/mocha.opts @@ -0,0 +1,3 @@ +--file ./test/init_mocha.js +--file ./tools/test/testGlobals.js +--exit \ No newline at end of file diff --git a/node_server/tools/alldocs/alldocs.js b/node_server/tools/alldocs/alldocs.js new file mode 100644 index 0000000..0a7bf69 --- /dev/null +++ b/node_server/tools/alldocs/alldocs.js @@ -0,0 +1,104 @@ +/** + * Code generation of the index file to pull together all the other documentation + */ +'use strict'; +var Handlebars = require('handlebars'); +var fs = require('fs'); +var os = require('os'); + +// +// Define the exports +// +module.exports = { + GenerateIndex: docgen +}; + +/** + * Function to generate the index AsciiDoc document for all files + * + * @param {Object} options - The docgen options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function docgen(options) { + return new Promise(function(resolve, reject) { + // + // Register some Handlebars helpers + // + registerHandlebarsHelpers(); + + // + // Register Handlebars partials + // + var handlebarsOpts = {noEscape: true}; + registerHandlebarsPartials(options.indexdocs.options, handlebarsOpts); + + // + // Process the templates with all the config info + // + var templateData = { + options: options + }; + + try { + var indexOptions = options.indexdocs.options; + + for (var name in indexOptions.pages) { + var dest = indexOptions.dest + name + '.adoc'; + console.log('Building %s -> %s', indexOptions.pages[name], dest); + + var templateText = fs.readFileSync(indexOptions.pages[name], 'utf-8'); + var func = Handlebars.compile(templateText, handlebarsOpts); + var result = func(templateData); + fs.writeFileSync(dest, result); + } + + // + // Resolve the promise to report success + // + resolve(); + } catch (err) { + reject(err); + } + + }); +} + +/** + * Register handlebars partials + * + * @param {Object} options - configuration options + * @param {Object} handlebarsOpts - options for handlebars + */ +function registerHandlebarsPartials(options, handlebarsOpts) { + for (var name in options.templates) { + registerHandlebarsPartial(options.templates[name], name, handlebarsOpts); + } +} + +function registerHandlebarsPartial(file, name, handlebarsOpts) { + var templateText = fs.readFileSync(file, 'utf-8'); + var template = Handlebars.compile(templateText, handlebarsOpts); + + Handlebars.registerPartial(name, template); +} + +/** + * Registers handlebars helper functions + */ +function registerHandlebarsHelpers() { + Handlebars.registerHelper('filenameify', filenameify); +} + +/** + * Handlebars helper function to replace all '/' with '-' to make a single + * filename rather than a nested path + * + * @param {Object} item - the item to look at. + * + * @return {Object} - a safestring of the type string + */ +function filenameify(item) { + var result = item.replace(/\//g, '-'); + return new Handlebars.SafeString(result); +} diff --git a/node_server/tools/alldocs/templates/adoc-index.handlebars b/node_server/tools/alldocs/templates/adoc-index.handlebars new file mode 100644 index 0000000..3829528 --- /dev/null +++ b/node_server/tools/alldocs/templates/adoc-index.handlebars @@ -0,0 +1,28 @@ +//// +THIS IS A COMMENT and doesn't appear in the final doc. +Edit the section below here to change the title, introduction, etc. +Just don't edit the bit after the next comment! +//// += Comcarde Bridge Architecture +(c) Comcarde Ltd +:toc: left +:toclevels: 3 +:numbered: + +//// +WARNING: EVERYTHING BELOW HERE IS USED FOR AUTO-GENERATION OF THE FULL DOC. +Don't touch unless you know what you are doing! +//// + +:wikidir: wiki +{{#each options.wikidocs.sources}} + +include::{wikidir}/{{filenameify slug}}.adoc[leveloffset=+{{level}}] + +{{/each}} + +:swaggerdir: swagger_api +:leveloffset: +1 +include::{swaggerdir}/{{options.api.indexPath}}[] +:leveloffset: -1 + diff --git a/node_server/tools/docgen/docgen.js b/node_server/tools/docgen/docgen.js new file mode 100644 index 0000000..10d6e35 --- /dev/null +++ b/node_server/tools/docgen/docgen.js @@ -0,0 +1,344 @@ +/** + * Code generation of the AngularJS client from a swagger file + */ +'use strict'; +var SwaggerParser = require('swagger-parser'); +var Handlebars = require('handlebars'); +var handlebarsHelpers = require('handlebars-helpers')({ + handlebars: Handlebars +}); +var _ = require('lodash'); +var fs = require('fs'); +var os = require('os'); + +// +// Define the exports +// +module.exports = { + Swagger2AsciiDoc: docgen +}; + +/** + * Function to generate the AsciiDoc documents from a Swagger definition file + * + * @param {String | Object} src - The swagger API as Object or path to file + * @param {Object} options - The docgen options + * @property {Object} options.parser - Swagger-parser options + * @property {Object} options.templates - Moustache templates + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function docgen(src, options) { + var p1 = new Promise(function(resolve, reject) { + // + // Register some Handlebars helpers + // + registerHandlebarsHelpers(); + + // + // Register Handlebars partials + // + var handlebarsOpts = {noEscape: true}; + registerHandlebarsPartials(options, handlebarsOpts); + + // + // Parse the swagger file, then use it as the data for applying the functions + // + var promise = SwaggerParser.parse(src); + promise.then(function(swagger) { + // + // Define all the data we need for the templates + // + var templateData = { + paths: options.pathsName, + definitions: options.definitionsName, + swagger: swagger + }; + + try { + for (var name in options.pages) { + var dest = options.dest + name + '.adoc'; + console.log('Building %s -> %s', options.pages[name], dest); + + var templateText = fs.readFileSync(options.pages[name], 'utf-8'); + var func = Handlebars.compile(templateText, handlebarsOpts); + var result = func(templateData); + fs.writeFileSync(dest, result); + } + + // + // Resolve the promise to report success + // + resolve(); + } catch (err) { + reject(err); + } + }); + promise.catch(function(err) { + reject(err); + }); + }); + return p1; +} + +/** + * Beautify and format the basic javascript. + * Uses JS-Beautify and JSCS to tidy up the code + * + * @param {String} original - the original JS strings + * @param {Object} options - options for the tidY + * + * @return {String} - the beautified string + */ +function formatAndTidy(original, options) { + return original; +} + +/** + * Register handlebars partials + * + * @param {Object} options - configuration options + * @param {Object} handlebarsOpts - options for handlebars + */ +function registerHandlebarsPartials(options, handlebarsOpts) { + for (var name in options.templates) { + registerHandlebarsPartial(options.templates[name], name, handlebarsOpts); + } +} + +function registerHandlebarsPartial(file, name, handlebarsOpts) { + var templateText = fs.readFileSync(file, 'utf-8'); + var template = Handlebars.compile(templateText, handlebarsOpts); + + Handlebars.registerPartial(name, template); +} + +/** + * Registers handlebars helper functions + */ +function registerHandlebarsHelpers() { + Handlebars.registerHelper('swaggerType', swaggerTypeHelper); + Handlebars.registerHelper('swaggerStringify', swaggerStringifyHelper); + Handlebars.registerHelper('validIdentifier', validIdentiferHelper); + Handlebars.registerHelper('multilineComment', multilineCommentHelper); + Handlebars.registerHelper('refToLink', refToLink); + Handlebars.registerHelper('swaggerRef', swaggerRef); + Handlebars.registerHelper('swaggerStringify', swaggerStringify); + Handlebars.registerHelper('ifUndefined', ifUndefined); + Handlebars.registerHelper('escapeAsciidocTable', escapeAsciidocTable); +} + +/** + * Handlebars helper function to find the best 'type' for a parameter. It + * is either the explicit `type` field, or the name of the `$ref` (with the + * '#/definitions/' stripped off). + * + * @param {Object} item - the item to look at. + * + * @return {Object} - a safestring of the type string + */ +function swaggerTypeHelper(item) { + var type = ''; + + if (item.hasOwnProperty('$ref')) { + var ref = item.$ref; + type = ref.replace('#/definitions/', ''); + type += 'T'; + } else if (item.hasOwnProperty('type')) { + type = item.type; + + // If we have an array, then find out what the type of items in the + // array is. + if (type === 'array') { + if (Array.isArray(item.items)) { + return swaggerTypeHelper(item.items[0]); + } else { + return swaggerTypeHelper(item.items); + } + } + } + return new Handlebars.SafeString(type); +} + +/** + * Simple helper to stringify a value and return it. + * + * @return {Object} - a stringified version of the string + */ +function swaggerStringifyHelper() { + return new Handlebars.SafeString(JSON.stringify(this)); +} + +/** + * Converts a string into a valid JS identifier (e.g. function name) by + * removing any invalid characters. + * + * @param {String} string - The string to validate/convert + * + * @return {Object} - a valid identifier + */ +function validIdentiferHelper(string) { + var result = string.replace(/([^a-zA-Z0-9]+)/g, ''); + return new Handlebars.SafeString(result); +} + +/** + * Converts a schema $ref to an asciidoc link to that schema name + * + * @param {String} string - The string to validate/convert + * + * @return {Object} - a valid identifier + */ +function refToLink(string) { + if (string && string.length) { + var result = /#\/.*\/(.*)/.exec(string)[1]; + result = '<<' + result + '>>'; + return new Handlebars.SafeString(result); + } else { + return new Handlebars.SafeString('!!UNDEFINED!!'); + } +} + +/** + * Converts a long line into a number of comment strings across several lines. + * + * @param {String} string - The string to validate/convert + * + * @return {Object} - a multi-line comment + */ +function multilineCommentHelper(string) { + var lineLength = 65; + var result = ' *'; + var currentLineLength = 2; + + // + // Swap CRLF or LF with a known character sequence. This sequence is + // space separated so it will be split on its own, even if it was tight + // against another word. + // + var CRLFReplacement = '~[NEWLINE]~'; + string = string.replace(/\r\n/g, ' ' + CRLFReplacement + ' '); + string = string.replace(/\n/g, ' ' + CRLFReplacement + ' '); + + // + // Split the string by spaces, so we will line wrap by word (rather than + // at an arbitrary point) + // + var splitLine = string.split(/\s+/); + + // + // Go through all the words, adding them to the line, and inserting new + // comment lines as the line length becomes too long. + // + for (var i = 0; i < splitLine.length; ++i) { + var word = splitLine[i]; + + if (!word) { + // + // Sometimes split will give a null entry (e.g. if the string + // ends on the split character). Just ignore it. + // + continue; + } else if (word === CRLFReplacement) { + // + // Fixup the CRLF replacement to put in a CRLF and the start of the + // next line. Then move on to the next word. + // + result += os.EOL + ' *'; + currentLineLength = 2; + continue; + } else if (currentLineLength + word.length + 1 > lineLength) { + // + // If we would exceed the line length, start a new line. + // + result += os.EOL + ' *'; + currentLineLength = 2; + } + + // + // Add the current word to the current line. + // + result += ' ' + word; + currentLineLength += word.length + 1; + } + return new Handlebars.SafeString(result); +} + +/** + * Follows the (internal) reference and sets that object as the context + * + * @param {Object} context - The context passed into the function + * @param {Object} options - The options object + * + * @returns {Object} - The result of the options.fn, or an error string + */ +function swaggerRef(context, options) { + //console.log("Context: ", context, options); + if (!context) { + return Handlebars.SafeString('REF LOOKUP FAILED'); + } + + var type = /#\/(.*)\//.exec(context)[1]; + var ref = /#\/.*\/(.*)/.exec(context)[1]; + + if ( + options.data.root.swagger.hasOwnProperty(type) && + options.data.root.swagger[type].hasOwnProperty(ref) + ) { + var newContext = options.data.root.swagger[type][ref]; + return options.fn(newContext); + } else { + return Handlebars.SafeString('REF LOOKUP FAILED'); + } +}; + +/** + * Stringify's an object (if it isn't undefined) + * + * @param {Object} context - The string + * + * @return {Object} - a valid identifier + */ +function swaggerStringify(context) { + return new Handlebars.SafeString( + _.isUndefined(context) ? '' : JSON.stringify(context) + ); +} + +/** + * Simple helper to escape asciidoc table separators + * + * @param {String} string - The string to validate/convert + * @return {Object} - a stringified version of the string + */ +function escapeAsciidocTable(string) { + const asciiDocChars = /([|])/g; + const result1 = string.replace(asciiDocChars, '\\$1'); + + // + // Also remove incorrect bolding + // + const escapeBold = /(\*\S*\*)/g; + const result2 = result1.replace(escapeBold, '\\$1'); + + //console.log('escapeAsciidoc: ', string); + //console.log(' ->: ', result); + return new Handlebars.SafeString(result2); +} + +/** + * Checks if an object exists (even if it is 'empty' + * + * @param {Object} conditional - The object we are testing + * @param {Object} options - The options object + * + * @return {Object} - the result + */ +function ifUndefined(conditional, options) { + if (_.isUndefined(conditional)) { + return options.fn(this); + } else { + return options.inverse(this); + } +} + diff --git a/node_server/tools/docgen/templates/adoc-definitions.handlebars b/node_server/tools/docgen/templates/adoc-definitions.handlebars new file mode 100644 index 0000000..6725b5f --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-definitions.handlebars @@ -0,0 +1,64 @@ +== Definitions +=== Simple Types + +[options="header"] +|=== +|Name|Description|Schema|Example +{{#each swagger.definitions}} +{{#unless allOf}} +{{#isnt type "object"}} +{{> propertiesRow}} +{{/isnt}} +{{/unless}} +{{/each}} +|=== + +=== Complex Types +{{!-- complex (object) types --}} +{{#each swagger.definitions}} +{{#is type "object"}} +==== {{@key}} [[{{@key}}]] {{!--Explicit anchor in same format as refToLink--}} +:hardbreaks: +{{description}} + +===== Properties +[options="header"] +|=== +|Name|Description|Schema|Example +{{#each properties}} +{{#isnt type "object"}} +{{> propertiesRow}} +{{/isnt}} +{{/each}} +|=== + +{{/is}} +{{/each}} + +{{!-- allOff items --}} +{{#each swagger.definitions}} +{{#if allOf}} +==== {{@key}} +:hardbreaks: +{{description}} + +===== Properties +[options="header"] +|=== +|Name|Description|Schema|Example +{{#each allOf}} + {{#if $ref}} + {{#swaggerRef $ref}} + {{#each properties}} + {{~> propertiesRow}} + {{/each}} + {{/swaggerRef}} + {{/if}} + {{#each properties}} + {{~> propertiesRow}} + {{/each}} +{{/each}} +|=== + +{{/if}} +{{/each}} diff --git a/node_server/tools/docgen/templates/adoc-overview.handlebars b/node_server/tools/docgen/templates/adoc-overview.handlebars new file mode 100644 index 0000000..87817b3 --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-overview.handlebars @@ -0,0 +1,36 @@ += {{swagger.info.title}} +Version: {{swagger.info.version}} +CONFIDENTIAL: DO NOT DISTRIBUTE WITHOUT EXPRESS WRITTEN PERMISSION FROM COMCARDE LTD + +== Overview +{{#if swagger.info.description}} +{{swagger.info.description}} +{{else}} +This section documents the REST API. available to the HTML5 & javascript-based +dashboard. For details on the security considerations relating to the design, +please see <>. +{{/if}} + +=== URI scheme +Base Path:: {{swagger.basePath}} +Schemes:: {{#each swagger.schemes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} + +=== Content Types +Consumes:: {{#each swagger.consumes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +Produces:: {{#each swagger.produces}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} + +=== Security Schemes +{{#each swagger.securityDefinitions}} +[[{{@key}}]]{{@key}}:: {{description}} +{{/each}} + +==== Default Security +{{#each swagger.security}} + {{#each this}} +* **{{@key}}** + {{/each}} +{{/each}} + +include::paths.adoc[] +include::responses.adoc[] +include::definitions.adoc[] diff --git a/node_server/tools/docgen/templates/adoc-parameters.handlebars b/node_server/tools/docgen/templates/adoc-parameters.handlebars new file mode 100644 index 0000000..de9cb1f --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-parameters.handlebars @@ -0,0 +1,14 @@ +[options="header"] +|=== +|Type|Name|Description|Required|Schema|Example +{{#each parameters}} +{{#if schema}} +|{{in}}|{{name}}|{{description}}|{{required}}|{{> schemaOrType}}|{{schema.example}} +{{/if}} +{{#if $ref}} +{{#swaggerRef $ref}} +|{{in}}|{{name}}|{{description}}|{{required}}|{{> schemaOrType}}|{{swaggerStringify example}} +{{/swaggerRef}} +{{/if}} +{{/each}} +|=== diff --git a/node_server/tools/docgen/templates/adoc-paths.handlebars b/node_server/tools/docgen/templates/adoc-paths.handlebars new file mode 100644 index 0000000..cffb950 --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-paths.handlebars @@ -0,0 +1,50 @@ +== Paths +{{#each swagger.tags}} + + {{~#each ../swagger.paths}} + {{~#each this~}} + {{~#if operationId}}{{#contains tags ../../name}} + +=== {{summary}} +Path:: {{uppercase @key}} {{@../key}} +Security Level:: +{{#if security~}} + {{#each security~}} + {{#each this~}} + <<{{@key}}>> + {{/each~}} + {{/each~}} +{{else~}} + {{#ifUndefined security~}} + {{#each ../../../swagger.security}} + {{#each this~}} + <<{{@key}}>>{{#unless @last}}, {{/unless}} + {{/each~}} + {{/each~}} + {{else~}} + No session required. + {{/ifUndefined~}} +{{/if}} + +==== Description +:hardbreaks: +{{description}} + +==== Parameters +{{#if parameters}} +{{> parameters swagger=../../../swagger}} +{{else}} +No parameters. +{{/if}} + +==== Responses +{{#if responses}} +{{> responses}} +{{else}} +No responses defined. +{{/if}} + {{~/contains}}{{/if~}} + {{/each~}} + {{/each~}} +{{/each}} + diff --git a/node_server/tools/docgen/templates/adoc-properties-row.handlebars b/node_server/tools/docgen/templates/adoc-properties-row.handlebars new file mode 100644 index 0000000..a48f9e4 --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-properties-row.handlebars @@ -0,0 +1 @@ +|[[{{@key}},{{@key}}]]{{@key}}|{{description}}|{{> schemaOrType}}|{{swaggerStringify example}} diff --git a/node_server/tools/docgen/templates/adoc-range.handlebars b/node_server/tools/docgen/templates/adoc-range.handlebars new file mode 100644 index 0000000..b6aff7e --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-range.handlebars @@ -0,0 +1,12 @@ +{{~#if min includeZero=true~}} + {{#if max includeZero=true~}} + {{#is max min~}} + [{{prefix}}{{min}}] + {{~else~}} + [{{prefix}}{{min}}->{{max}}] + {{~/is~}} + {{~else}}[{{prefix}}> {{min}}] + {{~/if~}} +{{~else~}} + {{~#if max includeZero=true}}[{{prefix}}< {{max}}] {{/if~}} +{{/if~}} diff --git a/node_server/tools/docgen/templates/adoc-response-definitions.handlebars b/node_server/tools/docgen/templates/adoc-response-definitions.handlebars new file mode 100644 index 0000000..3b9a537 --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-response-definitions.handlebars @@ -0,0 +1,37 @@ +== Responses +{{!-- complex (object) types --}} +{{#each swagger.responses}} +=== {{@key}} +:hardbreaks: +{{description}} + +{{#if headers}} +==== Headers +[options="header"] +|=== +|Name|Description|Schema|Example +{{#each headers}} + {{~> propertiesRow}} +{{/each}} +|=== +{{/if}} +{{#if schema}} +==== Schema +[options="header"] +|=== +|Name|Description|Schema|Example +{{#with schema}} + {{#if $ref}} + {{#swaggerRef $ref}} + {{#each properties}} + {{~> propertiesRow}} + {{/each}} + {{/swaggerRef}} + {{/if}} + {{#each properties}} + {{~> propertiesRow}} + {{/each}} +{{/with}} +|=== +{{/if}} +{{/each}} diff --git a/node_server/tools/docgen/templates/adoc-responses.handlebars b/node_server/tools/docgen/templates/adoc-responses.handlebars new file mode 100644 index 0000000..994d039 --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-responses.handlebars @@ -0,0 +1,7 @@ +[options="header"] +|=== +|HTTP Code|Description|Schema +{{#each responses}} +|{{@key}}|{{description}}|{{> schemaOrType}} +{{/each}} +|=== diff --git a/node_server/tools/docgen/templates/adoc-schema-or-type.handlebars b/node_server/tools/docgen/templates/adoc-schema-or-type.handlebars new file mode 100644 index 0000000..2b5d165 --- /dev/null +++ b/node_server/tools/docgen/templates/adoc-schema-or-type.handlebars @@ -0,0 +1,17 @@ +{{#if type}}`{{type}}` {{/if~}} +{{~> range min=minItems max=maxItems prefix="Items: "}} +{{#with items}}: {{> schemaOrType}}{{/with}} +{{#if pattern}}`{{escapeAsciidocTable pattern}}` {{/if~}} +{{#if format}}`{{escapeAsciidocTable format}}` {{/if~}} +{{~> range min=minimum max=maximum prefix="Range: "}} +{{~> range min=minLength max=maxLength prefix="Length: "}} +{{~#if enum}}`enum`: [ + {{~#each enum~}} + `{{swaggerStringify this}}`{{#unless @last}}, {{/unless~}} + {{/each}}] +{{~/if}} +{{~#if $ref}}{{refToLink $ref}}{{/if~}} +{{~#if schema}}{{#with schema}}{{> schemaOrType}}{{/with}}{{/if~}} +{{~#if allOf}} + {{~#each allOf~}}{{> schemaOrType}} {{/each}} +{{~/if}} diff --git a/node_server/tools/test/testConfigFile.json b/node_server/tools/test/testConfigFile.json new file mode 100644 index 0000000..2e3db31 --- /dev/null +++ b/node_server/tools/test/testConfigFile.json @@ -0,0 +1,18 @@ +{ + "CCServerVersion": "0.0.0.0-unittest", + "AESKey": "kJq5fW4m/lLG6oLTcM+fPFmlHL9FU9=N", + "hashedAESKey": "52f4dbb4b522dac266c9da3a1c57a81aec66953a0441874725d5b5d6031a6e00", + "HMACBytes": 10, + "passwordCryptoVersion": 2, + "CCWebsiteAddress": "unittest.example.com", + "rateLimits": { + "api": { + "windowMs": 10000, + "max": 900, + "delayAfter": 0, + "delayMs": 0 + } + }, + "worldpayPrimaryGateway": "https://localhost:19999/", + "isDevEnv": true +} \ No newline at end of file diff --git a/node_server/tools/test/testGlobals.js b/node_server/tools/test/testGlobals.js new file mode 100644 index 0000000..721d123 --- /dev/null +++ b/node_server/tools/test/testGlobals.js @@ -0,0 +1,8 @@ +/** + * @fileOverview Sets up some basic globals for use in unit testing + */ +const path = require('path'); + +global.rootPath = path.resolve(__dirname, '../../') + '/'; +global.pathPrefix = path.resolve(__dirname, '../../ComServe/') + '/'; +global.configFile = path.resolve(__dirname, './testConfigFile.json'); diff --git a/node_server/tools/wikiToSchema/wikiToSchema.js b/node_server/tools/wikiToSchema/wikiToSchema.js new file mode 100644 index 0000000..4bc6806 --- /dev/null +++ b/node_server/tools/wikiToSchema/wikiToSchema.js @@ -0,0 +1,614 @@ +/** + * @fileOverview Functions to build JSON schemas based on the typically way + * the function parameters are defined on the wiki. + * Loads each wiki page using canduit, then parses it and genereates + * the schemas based on the `Command Use` section of the page + * + * @see {@url http://10.0.10.242/w/tricore_architecture/server_interface/login_auth/} + */ + +'use strict'; + +var Promise = require('promise'); +var _ = require('lodash'); +var fs = require('fs'); +var os = require('os'); +var createCanduit = require('canduit'); + +// +// Define the exports +// +module.exports = { + Wiki2Schema: wiki2Schema +}; + +const DEFAULT_SCHEMA = { + 'id': '', + '$schema': 'http://json-schema.org/draft-04/schema#', + 'title': '', + 'description': '', + 'type': 'object', + 'required': [], + 'properties': {} +}; + +// +// Promise loop stuff from: +// http://stackoverflow.com/questions/17217736/while-loop-with-promises +// + +// Promise.loop([properties: object]): Promise() +// +// Execute a loop based on promises. Object 'properties' is an optional +// argument with the following fields: +// +// initialization: function(): Promise() | any, optional +// +// Function executed as part of the initialization of the loop. If +// it returns a promise, the loop will not begin to execute until +// it is resolved. +// +// Any exception occurring in this function will finish the loop +// with a rejected promise. Similarly, if this function returns a +// promise, and this promise is reject, the loop finishes right +// away with a rejected promise. +// +// condition: function(): Promise(result: bool) | bool, optional +// +// Condition evaluated in the beginning of each iteration of the +// loop. The function should return a boolean value, or a promise +// object that resolves with a boolean data value. +// +// Any exception occurring during the evaluation of the condition +// will finish the loop with a rejected promise. Similarly, it this +// function returns a promise, and this promise is rejected, the +// loop finishes right away with a rejected promise. +// +// If no condition function is provided, an infinite loop is +// executed. +// +// body: function(): Promise() | any, optional +// +// Function acting as the body of the loop. If it returns a +// promise, the loop will not proceed until this promise is +// resolved. +// +// Any exception occurring in this function will finish the loop +// with a rejected promise. Similarly, if this function returns a +// promise, and this promise is reject, the loop finishes right +// away with a rejected promise. +// +// increment: function(): Promise() | any, optional +// +// Function executed at the end of each iteration of the loop. If +// it returns a promise, the condition of the loop will not be +// evaluated again until this promise is resolved. +// +// Any exception occurring in this function will finish the loop +// with a rejected promise. Similarly, if this function returns a +// promise, and this promise is reject, the loop finishes right +// away with a rejected promise. +// +// @param {Object} properties - The properties +// @returns {Promise} - A promise +Promise.loop = function(properties) { + // Default values + properties = properties || {}; + properties.initialization = properties.initialization || function() { }; + properties.condition = properties.condition || function() { return true; }; + properties.body = properties.body || function() { }; + properties.increment = properties.increment || function() { }; + + // Start + return new Promise(function(resolve, reject) { + // + // Functions that will be used ofr the promise loop + // + var runInitialization; + var runCondition; + var runBody; + var runIncrement; + + runInitialization = function() { + Promise.resolve().then(function() { + return properties.initialization(); + }) + .then(function() { + process.nextTick(runCondition); + }) + .catch(function(error) { + reject(error); + }); + }; + + runCondition = function() { + Promise.resolve().then(function() { + return properties.condition(); + }) + .then(function(result) { + if (result) { + process.nextTick(runBody); + } else { + resolve(); + } + }) + .catch(function(error) { + reject(error); + }); + }; + + runBody = function() { + Promise.resolve().then(function() { + return properties.body(); + }) + .then(function() { + process.nextTick(runIncrement); + }) + .catch(function(error) { + reject(error); + }); + }; + + runIncrement = function() { + Promise.resolve().then(function() { + return properties.increment(); + }) + .then(function() { + process.nextTick(runCondition); + }) + .catch(function(error) { + reject(error); + }); + }; + + // Start running initialization + process.nextTick(runInitialization); + }); +}; + +/** + * Function to generate AsciiDoc from wiki documents downloaded from phabricator + * + * @param {Object} options - The options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function wiki2Schema(options) { + var p1 = new Promise(function(resolve, reject) { + // + // Create and authenticate client + // + var config = { + configFile: process.env.APPDATA + '\\.arcrc' + }; + createCanduit(config, function(err, canduit) { + if (err) { + reject(err); + } else { + loadPages(canduit, options) + .then(function() { + resolve(); + }) + .catch(function(err) { + reject(err); + }); + } + }); + }); + return p1; +} + +/** + * Loops around all the pages from the config, processing them 1 at a time + * + * @param {Object} canduit - The `canduit` object + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function loadPages(canduit, options) { + return new Promise(function(resolve, reject) { + // + // Loop over the provide wiki slugs, and download them in turn + // + var i; + Promise.loop({ + initialization: function() { + i = 0; + }, + condition: function() { + return i < options.sources.length; + }, + body: function() { + console.log('Processing: ', options.sources[i].slug); + return loadPage(canduit, options.sources[i].slug, options); + }, + increment: function() { + i++; + } + }) + .then(function() { + resolve(); + }) + .catch(function(error) { + console.log('ERROR:', error); + reject(error); + }); + }); +} + +/** + * Loads a single page from Phabricator, and passes it on for processing + * + * @param {Object} canduit - The `canduit` object + * @param {String} slug - The phabricator slug for the page e.g. 'webconsole/security/' + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function loadPage(canduit, slug, options) { + return new Promise(function(resolve, reject) { + // + // Download the page + // + canduit.exec('phriction.info', { + slug: slug + }, function(err, wikipage) { + if (err) { + reject(err); + } else { + console.log(' - Got: ', wikipage.title); + processPage(canduit, wikipage, options) + .then(function(res) { + resolve(res); + }) + .catch(function(err) { + reject(err); + }); + } + }); + }); +} + +/** + * Processes a single page from Phabricator, turning it from Remarkup (from + * Phabricator) into asciidoc (for asciidoctor) + * + * @param {Object} canduit - The `canduit` object + * @param {Object} page - The page JSON object from conduit + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function processPage(canduit, page, options) { + return new Promise(function(resolve, reject) { + // Get the command name + var cmdFinderRegex = /= Command=(\w*)/; + var cmd = cmdFinderRegex.exec(page.content); + if (cmd === null) { + console.log('- NO COMMAND FOUND'); + resolve(); + return; + } else { + console.log(' - command:', cmd[1]); + } + var commandName = cmd[1]; + + // Isolate the block + var blockFinderRegex = /= Command Use(.*?[\r\n]*?)*?= Valid/; + var block = blockFinderRegex.exec(page.content); + if (block === null) { + console.log(' - No block found'); + resolve(' - No block found'); + return; + } + + // Find the code at the top of the page + var codeFinderRegex = /({([\w\s":,/,.\-–'’+*<>#()=\[\]{}]*)})/; + var result = codeFinderRegex.exec(block[0]); + + if (result === null) { + console.log(' - nothing found'); + resolve(true); + return; + } else { + // + // Initialise the schema + // + let newSchema = _.cloneDeep(DEFAULT_SCHEMA); + newSchema.id = commandName; + newSchema.title = commandName; + newSchema.description = 'See http://10.0.10.242/w/' + page.slug; + + // + // Now parse out the lines + // + var propertyFinderRegex = /^\s+"(\w+)":(?:"(.*)")?.*(?:\/\/)(.*)?$/gm; + var properties; + while ((properties = propertyFinderRegex.exec(result[1])) !== null) { + console.log(' - ', properties[1]); + updateSchema(newSchema, properties[1], properties[2], properties[3]); + } + + // + // And write the schema out + // + var filename = commandName + '.json'; + var filepath = options.schemaDest + filename; + + savePage(filepath, JSON.stringify(newSchema, null, ' '), options) + .then(function() { + resolve(); + }) + .catch(function(err) { + reject(err); + }); + } + }); +} + +/** + * Updates the schema with info from line we got from the wiki + * + * @param {Object} schema - the schema to update + * @param {String} propName - The property name + * @param {String} propExample - Any example text + * @param {String} propComments - Any comments which we can use to infer types from + */ +function updateSchema(schema, propName, propExample, propComments) { + var i = 0; + var ref = null; + var others = null; + var useExample = false; + + var prop = initProp(schema, propName, propExample, propComments); + + ref = handleKnownNames(prop, propName, propExample, propComments); + + if (!ref) { + ref = handleKnownCommentTypes(prop, propName, propExample, propComments); + } + + if (!ref) { + // + // Deal with compound ones we can build based on the comments + // Note that the order is important as these will be contacatenated + // + var simpleTypesInComments = [ + 'generalText', + 'fullAlphaNumeric', + 'alpha', + 'paycodeString', + 'lowerCaseHex', + 'numeric', + 'version', + 'Dash', + 'Space', + 'fwslash', + 'hexadecimal' + ]; + let refTest = ''; + for (i = 0; i < simpleTypesInComments.length; ++i) { + let typeTest = new RegExp(simpleTypesInComments[i], 'i'); + if (propComments.match(typeTest)) { + refTest += simpleTypesInComments[i]; + } + } + if (refTest !== '') { + ref = refTest; + } + + // + // Search for number of characters + // + let stringLengthFinder = /(\d+)(?: to )?(\d+)? chars/i; + let lengths = stringLengthFinder.exec(propComments); + if (lengths !== null) { + if (lengths[2] === undefined) { + others = { + minLength: +lengths[1], + maxLength: +lengths[1] + }; + } else { + others = { + minLength: +lengths[1], + maxLength: +lengths[2] + }; + } + } + } + + if (!ref) { + // + // Basic types + // + if (propComments.match(/integer/i)) { + prop.type = 'number'; + prop.maxDecimalPlaces = 0; + + let lengthFinder = /(\d+)(?: to )?(\d+)?/i; + let lengths = lengthFinder.exec(propComments); + if (lengths !== null) { + if (lengths[2] === undefined) { + prop.minimum = +lengths[1]; + } else { + prop.minimum = +lengths[1]; + prop.maximum = +lengths[2]; + } + } + + // + // Search for defaults + // + let defaultFinder = /Default: (\d+)/i; + let defaults = defaultFinder.exec(propComments); + if (defaults !== null) { + prop.default = +defaults[1]; + } + } + } + + if (ref && !others) { + prop.$ref = 'defs/#/definitions/' + ref; + } else if (ref && others) { + prop.allOf = [ + others, + { + $ref: 'defs/#/definitions/' + ref + } + ]; + useExample = true; + } + + // + // Only add the example if we are using a basic case + // + if (propExample && useExample) { + if (others) { + prop.allOf[0].example = propExample; + } else { + prop.example = propExample; + } + } +} + +/** + * Initialises the property, and adds it to the required list if required + * + * @param {Object} schema - the schema + * @param {String} propName - The property name + * @param {String} propExample - Any example text + * @param {String} propComments - Any comments which we can use to infer types from + * + * @return {String?} A reference to a pre-defined definition + */ +function initProp(schema, propName, propExample, propComments) { + var prop = {}; + schema.properties[propName] = prop; + + // Is this optional? + if (propComments.match(new RegExp('opt', 'i')) === null) { + schema.required.push(propName); + } + + return prop; +} + +/** + * Finds references if we know the type of it based on the name of the property + * + * @param {Object} prop - the property to update + * @param {String} propName - The property name + * @param {String} propExample - Any example text + * @param {String} propComments - Any comments which we can use to infer types from + * + * @return {String?} A reference to a pre-defined definition + */ +function handleKnownCommentTypes(prop, propName, propExample, propComments) { + var ref; + // + // Compound types in the comments that only need a single ref + // + var compoundTypesInComments = [ + 'sha256', + {identifier: '_id', ref: 'uuid'}, + {identifier: 'MM-YY', ref: 'cardDate'}, + {identifier: 'lowerCaseHex, 24 chars', ref: 'uuid'}, + {identifier: 'BASE 64 Encoded Image', ref: 'base64Image'}, + {identifier: '8601', ref: 'timeStamp'} + ]; + for (let i = 0; i < compoundTypesInComments.length; ++i) { + var entry = compoundTypesInComments[i]; + var test = entry; + var refString = entry; + if (_.isObject(entry)) { + test = entry.identifier; + refString = entry.ref; + } + let refTest = new RegExp(test, 'i'); + if (propComments.match(refTest)) { + ref = refString; + break; + } + } + + return ref; +} + +/** + * Find references if we know the name of it based on the the comments + * + * @param {Object} prop - the property to update + * @param {String} propName - The property name + * @param {String} propExample - Any example text + * @param {String} propComments - Any comments which we can use to infer types from + * + * @return {String?} A reference to a pre-defined definition + */ +function handleKnownNames(prop, propName, propExample, propComments) { + var ref = null; + + var knownExactPropNames = [ + 'DeviceToken', 'SessionToken', 'DeviceUuid', 'ClientName', 'ClientID', 'Method', + 'OperatorName' + ]; + var knownLowerCasePropNames = [ + 'Longitude', 'Latitude', 'PhoneNumber', 'TimeStamp', 'CardPAN', 'UserImage', + 'FileType', 'ImageType', 'ImageRef', 'TipAmount' + ]; + + // + // Deal with know properties + // + if (knownExactPropNames.indexOf(propName) !== -1) { + ref = propName; + } + + if (knownLowerCasePropNames.indexOf(propName) !== -1) { + // Save the name with the first char lower-cased + ref = propName.charAt(0).toLowerCase() + propName.slice(1); + } + + // + // Some special cases + // + if (!ref) { + switch (propName) { + case 'Country': + prop.type = 'string'; + prop.enum = ['United Kingdom']; + prop.example = 'United Kingdom'; + return; + case 'Mode': + ref = 'testMode'; + break; + case 'DeviceNumber': + ref = 'phoneNumber'; + break; + } + } + + return ref; +} + +/** + * Saves the processed page to the specified filename + * + * @param {String} filename - The filename to save to + * @param {String} text - The text to save + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function savePage(filename, text, options) { + return new Promise(function(resolve, reject) { + fs.writeFile(filename, text, function(err) { + if (err) { + reject(err); + } else { + console.log(' - Written: ', filename); + resolve(filename); + } + }); + }); +} diff --git a/node_server/tools/wikidocs/wikidocs.js b/node_server/tools/wikidocs/wikidocs.js new file mode 100644 index 0000000..103cde8 --- /dev/null +++ b/node_server/tools/wikidocs/wikidocs.js @@ -0,0 +1,531 @@ +const fs = require('fs'); +const os = require('os'); +const Q = require('q'); +const _ = require('lodash'); +const createCanduit = require('canduit'); +const stringReplaceAsync = require('string-replace-async'); + +// +// Define the exports +// +module.exports = { + Wiki2AsciiDoc: wiki2asciidoc +}; + +/** + * Function to generate AsciiDoc from wiki documents downloaded from phabricator + * + * @param {Object} options - The options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function wiki2asciidoc(options) { + var p1 = new Promise(function(resolve, reject) { + // + // Create and authenticate client + // + var config = { + configFile: process.env.APPDATA + '\\.arcrc' + }; + createCanduit(config, function(err, canduit) { + if (err) { + reject(err); + } else { + loadPages(canduit, options) + .then(function() { + resolve(); + }) + .catch(function(err) { + reject(err); + }); + } + }); + }); + return p1; +} + +/** + * Loops around all the pages from the config, processing them 1 at a time + * + * @param {Object} canduit - The `canduit` object + * @param {Object} options - The config options + */ +async function loadPages(canduit, options) { + try { + for (let i = 0; i < options.sources.length; ++i) { + console.log('Processing: ', options.sources[i].slug); + await loadPage(canduit, options.sources[i].slug, options); + } + } catch (error) { + console.log('ERROR:', error); + throw error; + } +} + +/** + * Loads a single page from Phabricator, and passes it on for processing + * + * @param {Object} canduit - The `canduit` object + * @param {String} slug - The phabricator slug for the page e.g. 'webconsole/security/' + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function loadPage(canduit, slug, options) { + return new Promise(function(resolve, reject) { + // + // Download the page + // + canduit.exec('phriction.info', { + slug: slug + }, function(err, wikipage) { + if (err) { + reject(err); + } else { + console.log(' - Got: ', wikipage.title); + processPage(canduit, wikipage, options) + .then(function(res) { + resolve(res); + }) + .catch(function(err) { + reject(err); + }); + } + }); + }); +} + +/** + * Processes a single page from Phabricator, turning it from Remarkup (from + * Phabricator) into asciidoc (for asciidoctor) + * + * @param {Object} canduit - The `canduit` object + * @param {Object} page - The page JSON object from conduit + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function processPage(canduit, page, options) { + return new Promise(function(resolve, reject) { + // + // Turn the title into a filename + // + var filenameBase = page.slug.replace(/\//g, '-'); + var filename = options.dest + filenameBase + '.adoc'; + + // + // Modify the content to turn remarkup into asciidoc + // + // Add the title at the top and add an anchor based on the filename + // so we can use that for relative links. We define an `xreflabel` so + // that cross references get a nice name for the link. + // + // + var content = ''; + content += '= ' + page.title; + content += ' [[' + filenameBase + ',' + page.title + ']]\n\n'; + content += page.content; + + // + // Find all the file links - look like {F123[, =]} + // + var fileRegex = /{F(\d+)/g; + var files = []; + var file = null; + while ((file = fileRegex.exec(content)) !== null) { + // + // The array has multiple items which hold different parts + // of the result. We are interested in the second item which is + // the value from the match group. + // + files.push(file[1]); + } + + getFiles(canduit, files, options) + .then(function(savedFiles) { + convertRemarkup2Asciidoc(canduit, filename, content, savedFiles, options) + .then(function(res) { + resolve(res); + }) + .catch(function(err) { + reject(err); + }); + }) + .catch(function(err) { + reject(err); + }); + }); +} + +/** + * Get all the files in the provided list. These are the file ids that have + * been found in the text of the current page. + * + * @param {Object} canduit - The canduit object + * @param {string[]} files - The array of file ids to download + * @param {Object} options - The config options + */ +async function getFiles(canduit, files, options) { + console.log(' - Getting Files', files); + let savedFiles = {}; + + if (!files) { + console.log(' - skipping'); + return; //Nothing to do + } else { + // + // For loop around getting the files. + // + for (let i = 0; i < files.length; ++i) { + console.log(' -- Processing file: ', files[i]); + await getFile(canduit, files[i], options) + .then(function(result) { + savedFiles[result.id] = result; + }); + } + } + + return savedFiles; +} + +/** + * Gets the file data for the specified id + * + * @param {Object} canduit - The `canduit` object + * @param {string} fileId - The id of the file to download + * @param {Object} options - The config options + */ +function getFile(canduit, fileId, options) { + return new Promise(function(resolve, reject) { + // + // Getting an file is a 2 step process: + // 1. Get file info based on the `id` given + // 2. Use the `phid` from that result to download the file + // + // NOTE: the download is in base64, so needs to be converted then saved. + // + canduit.exec('file.info', { + id: fileId + }, function(err, fileInfo) { + if (err) { + reject(err); + } else { + console.log(' --- Got file info: ', fileInfo.mimeType); + + // + // Download the actual file data + // + canduit.exec('file.download', { + phid: fileInfo.phid + }, function(err, fileData) { + if (err) { + reject(err); + } else { + console.log(' ---- Got file data'); + saveFile(fileId, fileInfo.mimeType, fileData, options) + .then(function(res) { + resolve(res); + }) + .catch(function(err) { + reject(err); + }); + } + }); + } + }); + }); +} + +/** + * Saves the processed file to the specified filename + * + * @param {String} id - The id (not phid) of the object + * @param {String} mimeType - The mime type of the file (to assign line ending) + * @param {String} data - The base64 data to save + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function saveFile(id, mimeType, data, options) { + return new Promise(function(resolve, reject) { + // + // Guess the extension based on the mimeType + // + var ext; + switch (mimeType) { + case 'image/png': + ext = 'png'; + break; + case 'image/jpeg': + case 'image/jpg': + ext = 'jpg'; + break; + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + ext = 'xlsx'; + break; + case 'text/plain': + ext = 'txt'; + break; + } + if (!ext) { + reject('Unknown mimeType for F{' + id + '}'); + return; + } + + // + // Build the filename + // + var filename = id + '.' + ext; + var filepath = options.fileDest + filename; + + // + // Load the base64 into a buffer + // + var fileBuffer = new Buffer(data, 'base64'); + + // + // And write it out + // + fs.writeFile(filepath, fileBuffer, function(err) { + if (err) { + reject(err); + } else { + console.log(' ----- saved file: ', filename); + var result = { + id: id, + mimeType: mimeType, + filename: filename, + filepath: filepath + }; + resolve(result); + } + }); + }); +} + +/** + * Converts remarkup styled text to asciidoc styled text. This is mostly + * done through regular expressions. + * + * @param {Object} canduit - The canduit call handler + * @param {String} filename - The filename to eventually save as + * @param {String} content - The content to process + * @param {Object} savedFiles - The list of files that have been downloaded for this content + * @param {Object} options - The config options + */ +async function convertRemarkup2Asciidoc(canduit, filename, content, savedFiles, options) { + + // + // Make sure any titles have a full blank space above them (or + // asciidoc just thinks the = is part of the preceding paragraph + // + content = content.replace(/(\n)=/g, '\n\n='); + + // + // External Links: + // Turn `[[http://example.com/ | link name]]` into + // link: `++http://example.com++[link name]` + // + // Note: also works for https://..., ftp://... etc. + // + content = content.replace(/\[\[(.*?):\/\/(.*?)\|(.*?)\]\]/g, 'link:++$1://$2++[$3]'); + + // + // Internal Links: + // Turn `[[internal/wiki/link/ | link name]]` into + // link: `<>` + // + // Note: we cheat a bit here, because we know all external links have + // already been converted, the remaining ones must be relative links. + // We also have to use a function as the replacement so we can convert + // the path to the slug text + // + content = content.replace( + /\[\[(.*?)\|(.*?)\]\]/g, + function(match, p1, p2, offset, string) { + // Turn the path into a slug by replacing / with - + var slug = p1.replace(/\//g, '-'); + slug = slug.replace(/\s/g, ''); + // Now build the asciidoc internal link + return '<<' + slug + ',' + p2 + '>>'; + }); + + // + // Similar to the above, but for links that don't have an explicit link name + // e.g. [[ /some/wiki/path ]] + // + // Some internal links don't have labels, so we have to look them up in the wiki + // to find their nice names. This is asynchronous. + // + content = await stringReplaceAsync( + content, + /\[\[([^:,]*?)\]\]/g, + async function(match, p1, offset, string) { + // The phriction slug is the matched param, trimmed of whitespace, + // with any trailing anchor removed + let slug = _.trim(p1); + const anchorPos = slug.indexOf('#'); + if (anchorPos > 0) { + slug = slug.slice(0, anchorPos); + } + + // + // Get the info + // + const info = await Q.ninvoke( + canduit, + 'exec', + 'phriction.info', + { + slug: slug + }) + .catch((error) => { + console.log(' -- FAILED to find phriction doc <', slug, '> [', error, ']'); + return Q.reject(error); + }); + + // Turn the path into an anchor by replacing / with - + let anchor = info.slug.replace(/\//g, '-'); + anchor = anchor.replace(/\s/g, ''); + + // Now build the asciidoc internal link + return '<<' + anchor + ', ' + info.title + '>>'; + }); + + // + // Turn HTML tables syntax into asciidoc. + // NOTE: the order of these regexes is important - the ones that strip + // out unnecessary items come first to avoid removing newlines + // deliberately inserted by other items + // + content = content.replace(/\s*(<\/th>|<\/td>)/g, ''); + content = content.replace(/\s*(<\/tr>)/g, ''); + content = content.replace(/\s*(|)/g, '|'); + content = content.replace(/\s*()/g, '\n[options="header"]\n|==='); + content = content.replace(/\s*()/g, '\n'); + content = content.replace(/\s*(<\/table>)/g, '\n|===\n'); + + // + // Turn the other table syntax into asciidoc + // + content = content.replace(/\n\n\|/g, '\n\n[options="header"]\n|===\n|'); + content = content.replace(/\n(?:\|-+)+\|?/g, ''); // Remove the header line + // + // Add a closing table marker if: + // 1. A | is the last thing in the file excluding newlines. i.e. table at end + // 2. A | is followed by a blank line then more text. i.e. table in middle + // + content = content.replace(/(?:\|\n*$)|(?:\|\n[^\|])/g, '|\n|===\n\n'); + content = content.replace(/\|\n/g, '\n'); // Remove extra | at end of every line + + // + // Italic phrases `//phrase//` -> `_phrase_` + // Monsopace `##phrase##` -> `\`phrase\`` + // + content = content.replace(/(\s)\/\/(.*?)\/\//g, '$1_$2_'); + content = content.replace(/([\s\(\/])##(.*?)\##/g, '$1`$2`'); + + // + // Make sure WARNING:, NOTE: etc. have a clear line above them + // + content = content.replace(/^([A-Z]*?:)/gm, '\n$1'); + + // + // Convert list formats + // `1.` -> `.` + // `#` -> '.' + // `-` -> `*` + // Note: Remarkup nesting is either multiple dots/dashs, or spacing driven, + // but asciidoc only does the former. + // Note: Lists must have a blank line above + // + content = content.replace(/^( *)\d+\./gm, '$1.'); + content = content.replace(/^( *)###/gm, '$1...'); // 3 deep duplicates + content = content.replace(/^( *)##/gm, '$1..'); // 2 deep duplicates + content = content.replace(/^( *)#/gm, '$1.'); // 1 deep + content = content.replace(/^( *)---/gm, '$1***'); // 3 deep duplicates + content = content.replace(/^( *)--/gm, '$1**'); // 2 deep duplicates + content = content.replace(/^( *)-/gm, '$1*'); // 1 deep + content = content.replace(/^(\.|\*)/gm, '\n\n$1'); // Blank lines + + // + // Space-based nesting (` *`) to duplicate-based nesting (`**`) + // + content = content.replace(/^ (\*|\.)/gm, '$1$1'); + + // + // Space-delimited code with a specified language + // First line is easy, adding the dashes at the end of a block is + // a bit more involved! + // + content = content.replace(/^ {2,}lang=(.*)/gm, '[source,$1]\n----'); + content = content.replace(/(\[source,.*?\]\n----\n( {2,}.+(\n)?)*)/g, '$1\n----\n'); + + // + // Increase the level of all headers by 1 level (except for the title + // header we added from the page title. + // + content = content.replace(/^=/gm, '=='); + content = content.replace(/^==/, '='); // Put the top level back + + // + // Replace file references with asciidoc image references. + // WARNING: we only do this for images, not other file types. + // There's 2 parts to this: + // 1. We need to lookup the filename in the data we have been given + // 2. Included images must start a line on their own + // + function filenameLookup(files, match, p1, p2, offset, string) { + console.log('- matching file [%s]', p1); + if (files.hasOwnProperty(p1)) { + var file = files[p1]; + + // + // Check if the MIME type is an image + // + switch (file.mimeType) { + case 'image/png': + case 'image/jpeg': + case 'image/jpg': + break; + default: + console.log('-- Skipping unsupported file type: ', file.mimeType); + return '__Unsupported file__:' + file.filename; // A filetype we don't support + } + return 'image::' + file.filename + '[]'; + } else { + throw ('Matching file not found'); + } + } + var filenameLookupFunc = filenameLookup.bind(null, savedFiles); + + content = content.replace(/{F(\d*)(, *.*?)*}/g, filenameLookupFunc); + content = content.replace(/\s*image::/g, '\n\nimage::'); + + // + // Then save of the converted page + // + return savePage(filename, content); +} + +/** + * Saves the processed page to the specified filename + * + * @param {String} filename - The filename to save to + * @param {String} text - The text to save + * @param {Object} options - The config options + * + * @returns {Promise} Promise that is resolved/rejected on success/failure + */ +function savePage(filename, text, options) { + return new Promise(function(resolve, reject) { + fs.writeFile(filename, text, function(err) { + if (err) { + reject(err); + } else { + console.log(' - Written: ', filename); + resolve(filename); + } + }); + }); +} diff --git a/node_server/utils/acquirers/acquirer.js b/node_server/utils/acquirers/acquirer.js new file mode 100644 index 0000000..4e6c8b1 --- /dev/null +++ b/node_server/utils/acquirers/acquirer.js @@ -0,0 +1,205 @@ +/** + * Functions to interact with 3rd party merchanct aquirers + */ +'use strict'; + +var Q = require('q'); +var errors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +var credorax = require(global.pathPrefix + '../utils/acquirers/credorax.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var config = require(global.configFile); +var testAcquirer = require(global.pathPrefix + '../utils/acquirers/test_acquirer.js'); +var worldpayAcquirer = require(global.pathPrefix + '../utils/acquirers/worldpay_acquirer.js'); +var demoAcquirer = require(global.pathPrefix + '../utils/acquirers/demo_acquirer.js'); +var forceAcquirer = null; // Set to 'Test' to force use of Test acquirer + +module.exports = { + invalidateMerchantAccount: invalidateMerchantAccount, + payTokenised: payTokenised, + payTransaction: payTransaction, + tokeniseCard: tokeniseCard, + + validateMerchantAccount: validateMerchantAccount, + + ERRORS: errors +}; + +/** + * Define the list of acquirers we have available + */ +const ACQUIRERS = { + Test: testAcquirer, + Worldpay: worldpayAcquirer, + Demo: demoAcquirer +}; + +/** + * Generic function to call the appropriate implementation based on the given + * acquirer name and function name. + * + * @param {string} acquirer - the acquirer that should be used + * @param {string} functionName - the name of the function to call + * @param {any[]} params - array of parameters for the function to be called + * @returns {Promise} - the result of the function or rejects with UNKNOWN_ACQUIRER + */ +function callImplIfExists(acquirer, functionName, params) { + const acquirerName = forceAcquirer || acquirer; + const acquirerImpl = ACQUIRERS[acquirerName]; + if (!acquirerImpl || !acquirerImpl[functionName]) { + return Q.reject({name: errors.UNKNOWN_ACQUIRER}); + } + + return acquirerImpl[functionName].apply(null, params); +} + +/** + * Attempts to invalidate the token with the merchant enquirer + * + * @param {string} acquirer - The merchant acquirer's name + * @param {string} token - The token to be invalidated + * @param {string} merchantID - The merchant ID for the merchant account + * @param {string} cipher - The merchant cipher for the merchant account + * @param {string} accountID - The accountID for creating a tracking id + * + * @returns {promise} - A promise that resolves on success, or rejects on fail + */ +function invalidateMerchantAccount(acquirer, token, merchantID, cipher, accountID) { + const acquirerName = forceAcquirer || acquirer; + const acquirerImpl = ACQUIRERS[acquirerName]; + if (!acquirerImpl || !acquirerImpl.invalidateMerchantAccount) { + return Q.reject({name: errors.UNKNOWN_ACQUIRER}); + } + + return acquirerImpl.invalidateMerchantAccount(token, merchantID, cipher, accountID); +} + +/** + * Makes a payment from customer to merchant using the specified acquirer. This + * requires the card to be pre-tokenised by the selected acquirer. + * + * @param {Object} transaction - The transaction being completed + * @param {Object} customerAccount - The customer's payment account + * @param {string} customerIP - The IP address the customer is connected from + * @param {Object} merchantAccount - The merchant's payment account + * + * @returns {Promise} - A promise that resolves on success, or rejects on fail + */ +function payTokenised( + transaction, + customerAccount, + customerIP, + merchantAccount +) { + // + // Check the token exists + // + if (!customerAccount.Token) { + return Q.reject({name: errors.NO_TOKEN}); + } + + // + // Check the merchant display name is long enough + // + if (utils.MinDisplayNameLength > transaction.MerchantDisplayName.length) { + return Q.reject({name: errors.INVALID_MERCHANT_NAME}); + } + + switch (forceAcquirer || merchantAccount.AcquirerName) { + case 'Credorax': + return credorax.payTokenised( + transaction, + customerAccount, + customerIP, + merchantAccount + ); + + case 'Test': + // Run the test version. Note that `testAcquirer` will be undefined + // unless this is in dev mode. + return testAcquirer.payTokenised( + transaction, + customerAccount, + customerIP, + merchantAccount + ); + // Else fall through to default. + + default: + return Q.reject({name: errors.UNKNOWN_ACQUIRER}); + } +} + +/** + * Makes a payment from customer to merchant using the appropriate acquirer + * + * @param {Object} client - the client making the payment + * @param {Object} device - the device making the payment + * @param {Object} data - various neccessary data + * @param {String} data.ClientKey - the client key required to decrypt the payment details + * @param {String} data.ipAddress - ipAddress of the client + * @param {Object} [data.cardDetails] - optional decrypted card details + * @param {Object} transaction - The transaction object with the payment info + * @param {Object} merchantInfo - Merchant account and address info + * @param {Object} customerInfo - Customer account and address info + * + * @returns {Promise} - Resolves to payment info, or rejects ERRORS value + */ +function payTransaction( + client, + device, + data, + transaction, + merchantInfo, + customerInfo +) { + // + // Check the merchant display name is long enough + // + if (utils.MinDisplayNameLength > transaction.MerchantDisplayName.length) { + return Q.reject({name: errors.INVALID_MERCHANT_NAME}); + } + + const acquirerName = forceAcquirer || merchantInfo.account.AcquirerName; + const acquirer = ACQUIRERS[acquirerName]; + if (!acquirer || !acquirer.payTransaction) { + return Q.reject({name: errors.UNKNOWN_ACQUIRER}); + } + + // + // Call the specific aquirer to process the transaction + // + return acquirer.payTransaction(client, device, data, transaction, merchantInfo, customerInfo); +} + +/** + * Validates that a merchant account is valid with the given acquirer + * + * @param {Object} account - the account to validate + * @returns {Promise} - resolves on succes or rejects with ERRORS value + */ +function validateMerchantAccount(account) { + const acquirerName = forceAcquirer || account.AcquirerName; + const acquirer = ACQUIRERS[acquirerName]; + if (!acquirer || !acquirer.validateMerchantAccount) { + return Q.reject({name: errors.UNKNOWN_ACQUIRER}); + } + + // + // Call the specific aquirer to process the transaction + // + return acquirer.validateMerchantAccount(account); +} + +/** + * Tokenises the card and returns some interesting information about it, along + * with the encrypted token (assuming the appropriate keys are provided). + * + * @param {string} acquirer - the acquirer to use to tokenise + * @param {Object} cardDetails - the card details to tokenise + * @param {string?} clientKey - the client Key (to encrypt the token) + * @param {string?} clientID - the client ID (to encrypt the token) + * @returns {Promise} - a promise for the successful tokenisation + */ +function tokeniseCard(acquirer, cardDetails, clientKey, clientID) { + return callImplIfExists(acquirer, 'tokeniseCard', [cardDetails, clientKey, clientID]); +} diff --git a/node_server/utils/acquirers/acquirer_errors.js b/node_server/utils/acquirers/acquirer_errors.js new file mode 100644 index 0000000..5160dab --- /dev/null +++ b/node_server/utils/acquirers/acquirer_errors.js @@ -0,0 +1,33 @@ +/** + * General error messages for merchant acquirer functions + */ +'use strict'; + +const ERRORS = { + UNKNOWN_ACQUIRER: 'BRIDGE: UNKNOWN ACQUIRER', + INVALID_COMBINATION: 'BRIDGE: CANT USE PAYMENT METHOD WITH ACQUIRER', + + ACQUIRER_DOWN: 'BRIDGE: CANT COMMUNICATE WITH ACQUIRER', + + INVALID_MERCHANT_NAME: 'BRIDGE: MERCHANT NAME TOO SHORT', + INVALID_MERCHANT_ACCOUNT_DETAILS: 'BRIDGE: MERCHANT ACCOUNT DETAILS MISSING OR CORRUPT', + INVALID_CARD_DETAILS: 'BRIDGE: CARD DETAILS MISSING OR CORRUPTED', + TOKEN_ENCRYPTION_FAILED: 'BRIDGE: TOKEN ENCRYPTION FAILED', + + CREDORAX_CANT_DISABLE_TOKEN: 'BRIDGE: CREDORAX CANT DISABLE TOKEN', + + ACQUIRER_UNKNOWN_ERROR: 'BRIDGE: UNKNOWN ACQUIRER ERROR', + ACQUIRER_BAD_REQUEST: 'BRIDGE: ACQUIRER: BAD REQUEST', + ACQUIRER_TKN_EXPIRED: 'BRIDGE: ACQUIRER: TOKEN EXPIRED', + ACQUIRER_INVALID_PAYMENT_DETAILS: 'BRIDGE: ACQUIRER: UNSUPPORTED OR INVALID PAYMENT DETAILS', + ACQUIRER_UNAUTHORIZED: 'BRIDGE: ACQUIRER: UNAUTHORIZED', + ACQUIRER_MERCHANT_DISABLED: 'BRIDGE: ACQUIRER: MERCHANT DISABLED', + ACQUIRER_TOKEN_NOT_FOUND: 'BRIDGE: ACQUIRER: TOKEN NOT FOUND', + ACQUIRER_INTERNAL_SERVER_ERROR: 'BRIDGE: ACQUIRER: INTERNAL SERVER ERROR AT ACQUIRER', + + NO_TOKEN: 'BRIDGE: NO TOKENISED CARD TO PAY WITH', + CARD_EXPIRED: 'BRIDGE: CARD HAS EXPIRED', + PAYMENT_FAILED_UNSPECIFIED: 'BRIDGE: UNSPECIFIED PAYMENT FAILURE' +}; + +module.exports = ERRORS; diff --git a/node_server/utils/acquirers/credorax.js b/node_server/utils/acquirers/credorax.js new file mode 100644 index 0000000..62c11f5 --- /dev/null +++ b/node_server/utils/acquirers/credorax.js @@ -0,0 +1,141 @@ +/** + * Functions to interact with Credorax + * This is based on the Credorax ePower Payment API Specification. + * @see {@url http://epower.credorax.com/home} + * The API version at the time this file was cared is 4.15 Rev 1 Jan 2016 + */ +'use strict'; + +var Q = require('q'); +var _ = require('lodash'); +var config = require(global.configFile); +var errors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +var log = require(global.pathPrefix + 'log.js'); +var sms = require(global.pathPrefix + 'sms.js'); +var credorax = require(global.pathPrefix + 'credorax.js'); + +module.exports = { + invalidateToken: invalidateToken, + payTokenised: payTokenised +}; + +/** + * Attempts to invalidate the token with Credorax + * + * @param {string} token - The token to be invalidated + * @param {string} merchantID - The merchant ID the card was tokenised with + * @param {string} cipher - The merchant cipher the card was tokenised with + * @param {string} accountID - The accountID for creating a tracking id + * + * @returns {promise} - A promise that resolves on success, or rejects on fail + */ +function invalidateToken(token, merchantID, cipher, accountID) { + // + // Check if we have anything to invalidate + // + if (!token) { + return Q.resolve(); + } + + // + // Setup the API request to invalidate a token. + // + var command = { + 'O': '16', // Code 16 is Block Token (see page 12 of API docs) + a1: 'DA' + accountID, // Request ID (unique number defined by us) + g1: token // The token to be invalidated + }; + + return Q.nfcall( + credorax.CredoraxFunction, + command, + merchantID, + cipher) + .then(function(response) { + if (response.z2 === '0') { + return Q.resolve(); + } else { + return Q.resolve(response); + } + }) + .catch(function(error) { + credorax.commsFailure('webConsole.onCommunicationFailure'); + return Q.reject({name: errors.CREDORAX_DOWN}); + }); +} + +/** + * Makes a payment from customer to merchant using the specified acquirer. This + * requires the card to be pre-tokenised by the selected acquirer. + * + * @param {Object} transaction - The transaction being completed + * @param {Object} customerAccount - The customer's payment account + * @param {string} customerIP - The IP address that the customer connects from + * @param {Object} merchantAccount - The merchant's payment account + * + * @returns {Promise} - A promise that resolves on success, or rejects on fail + */ +function payTokenised(transaction, customerAccount, customerIP, merchantAccount) { + // + // Setup the API request + // + var billingDescriptor = + 'COMCARDE *' + + _.truncate(transaction.MerchantDisplayName, {length: 13}); + var command = { + 'O': '11', // Code 11 is "Use Token - Sale" + 'a1': transaction._id.toString(), // Use the ID as the RequestID + 'a4': transaction.TotalAmount, + 'd1': customerIP, + 'g1': customerAccount.Token, + 'i2': billingDescriptor + }; + + // + // Make the call + // + return Q.nfcall( + credorax.CredoraxFunction, + command, + merchantAccount.AcquirerMerchantID, + merchantAccount.AcquirerCipher + ).then( + function(response) { + if (response.z2 === '0') { + // + // Success + // + return Q.resolve({ + reference: response.z13, + authCode: response.z4, + riskScore: response.z5, + avsResponse: response.z9, + responseID: response.z1, + saleTime: new Date() + }); + } else if (response.z3.indexOf('Card is expired') !== -1) { + // + // Specific error: card is expired + // + return Q.reject({ + name: errors.CARD_EXPIRED, + reason: response.z3 + }); + } else { + // + // Other unspecified errors + // + return Q.reject({ + name: errors.PAYMENT_FAILED_UNSPECIFIED, + reason: response.z3 + }); + } + }, + function(error) { + credorax.commsFailure('webConsole.onCommunicationFailure'); + return Q.reject({ + name: errors.CREDORAX_DOWN, + reason: error + }); + }); +} diff --git a/node_server/utils/acquirers/demo_acquirer.js b/node_server/utils/acquirers/demo_acquirer.js new file mode 100644 index 0000000..5004e3d --- /dev/null +++ b/node_server/utils/acquirers/demo_acquirer.js @@ -0,0 +1,89 @@ +/** + * Functions to interact with Worldpay + * This is based on the Worldpay JSON API Specification. + * @see {@url https://developer.worldpay.com/jsonapi/api} + */ +'use strict'; + +const Q = require('q'); +const _ = require('lodash'); +const debug = require('debug')('utils:acquirers:worldpay'); +const errors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +const encryption = require(global.pathPrefix + '../utils/encryption.js'); + +module.exports = { + invalidateMerchantAccount: invalidateMerchantAccount, + payTransaction: payTransaction, + validateMerchantAccount: validateMerchantAccount +}; + +/** + * Demo accounts don't have any stored tokens, so nothing to do here + * + * @param {string} token - The token to be invalidated + * @param {string} merchantID - The merchant ID of the account + * @param {string} cipher - The merchant cipher of the account + * @param {string} accountID - The accountID for creating a tracking id + * + * @returns {promise} - A promise that resolves on success, or rejects on fail + */ +function invalidateMerchantAccount(token, merchantID, cipher, accountID) { + return Q.resolve(); +} + +/** + * Makes a payment from customer to merchant using the appropriate acquirer + * + * @param {Object} client - the client making the payment + * @param {Object} device - the device the client is using + * @param {Object} data - various neccessary data + * @param {String} data.ClientKey - the client key required to decrypt the payment details + * @param {String} data.ipAddress - ipAddress of the client + * @param {Object} [data.cardDetails] - optional decrypted card details + * @param {Object} transaction - The transaction object with the payment info + * @param {Object} merchantInfo - Merchant account and address info + * @param {Object} customerInfo - Customer account and address info + * + * @returns {Promise} - Resolves to payment info, or rejects ERRORS value + */ +function payTransaction( + client, + device, + data, + transaction, + merchantInfo, + customerInfo +) { + /** + * Check that we have a payment method we can use + */ + if ( + customerInfo.account.AcquirerName !== 'Demo' && + customerInfo.account.AccountType !== 'Direct Credit/Debit Card Payment' + ) { + return Q.reject({name: errors.INVALID_COMBINATION}); + } + + // + // Always return success + // + const info = { + SaleReference: 'DEMO SALE REF', + SaleAuthCode: 'DEMO SALE AUTH', + RiskScore: 0, + AVSResponse: '', + GatewayResponse: 'DEMO SALE G-RESPONSE', + }; + return Q.resolve(info); +} + +/** + * Validates that a merchant account is valid for Demo. This always returns + * success + * + * @param {Object} account - the account to validate + * @returns {Promise} - resolves on succes or rejects with ERRORS value + */ +function validateMerchantAccount(account) { + return Q.resolve(); // No other tests neccessary for a demo account +} diff --git a/node_server/utils/acquirers/test_acquirer.js b/node_server/utils/acquirers/test_acquirer.js new file mode 100644 index 0000000..2a63923 --- /dev/null +++ b/node_server/utils/acquirers/test_acquirer.js @@ -0,0 +1,103 @@ +/** + * Functions to fake an acquirer for test purposes + */ +'use strict'; + +var Q = require('q'); +var _ = require('lodash'); +var errors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +var log = require(global.pathPrefix + 'log.js'); + +module.exports = { + invalidateMerchantAccount: invalidateMerchantAccount, + payTokenised: payTokenised +}; + +/** + * Define what we want the test results to be. + * Change these value to change the outcome + */ +const invalidateTokenResultOptions = { + NO_TOKEN: 1, + COMMS_ERROR: 2, + SUCCESS: 3, + FAIL: 4 +}; +var invalidateTokenResult = invalidateTokenResultOptions.SUCCESS; + +const payTokenisedResultOptions = { + COMMS_ERROR: 1, + SUCCESS: 2, + CARD_EXPIRED: 3, + OTHER_FAIL: 4 +}; +var payTokenisedResult = payTokenisedResultOptions.SUCCESS; + +/** + * Attempts to invalidate the token + * + * @param {string} token - The token to be invalidated + * @param {string} merchantID - The merchant ID the card was tokenised with + * @param {string} cipher - The merchant cipher the card was tokenised with + * @param {string} accountID - The accountID for creating a tracking id + * + * @returns {promise} - A promise that resolves on success, or rejects on fail + */ +function invalidateMerchantAccount(token, merchantID, cipher, accountID) { + switch (invalidateTokenResult) { + case invalidateTokenResultOptions.NO_TOKEN: + return Q.resolve(); + + case invalidateTokenResultOptions.COMMS_ERROR: + return Q.reject({name: errors.CREDORAX_DOWN}); + + case invalidateTokenResultOptions.FAIL: + // Fake a credorax error response + return Q.resolve({ + z2: -10, + z3: 'A fake error' + }); + + case invalidateTokenResultOptions.SUCCESS: + return Q.resolve(); + }; +}; + +/** + * Makes a payment from customer to merchant. + * + * @param {Object} transaction - The transaction being completed + * @param {Object} customerAccount - The customer's payment account + * @param {string} customerIP - The IP address that the customer connects from + * @param {Object} merchantAccount - The merchant's payment account + * + * @returns {Promise} - A promise that resolves on success, or rejects on fail + */ +function payTokenised(transaction, customerAccount, customerIP, merchantAccount) { + switch (payTokenisedResult) { + case payTokenisedResultOptions.COMMS_ERROR: + return Q.reject({name: errors.CREDORAX_DOWN}); + + case payTokenisedResultOptions.CARD_EXPIRED: + return Q.reject({ + name: errors.CARD_EXPIRED, + reason: 'Test acquirer pretending card has expired' + }); + + case payTokenisedResultOptions.OTHER_FAIL: + return Q.reject({ + name: errors.PAYMENT_FAILED_UNSPECIFIED, + reason: 'Test aquirer pretending other error has happened' + }); + + case payTokenisedResultOptions.SUCCESS: + return Q.resolve({ + reference: '611111111111', + authCode: 'TESTING', + riskScore: '0', + avsResponse: '', + responseID: '8a829441111111111111111111111111', + saleTime: new Date() + }); + } +} diff --git a/node_server/utils/acquirers/worldpay_acquirer.js b/node_server/utils/acquirers/worldpay_acquirer.js new file mode 100644 index 0000000..9c470e7 --- /dev/null +++ b/node_server/utils/acquirers/worldpay_acquirer.js @@ -0,0 +1,592 @@ +/** + * Functions to interact with Worldpay + * This is based on the Worldpay JSON API Specification. + * @see {@url https://developer.worldpay.com/jsonapi/api} + */ +'use strict'; + +const Q = require('q'); +const _ = require('lodash'); +const debug = require('debug')('utils:acquirers:worldpay'); + +const utils = require(global.pathPrefix + 'utils.js'); +const errors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); +const worldpay = require(global.pathPrefix + 'worldpay.js'); +const encryption = require(global.pathPrefix + '../utils/encryption.js'); +const formatting = require(global.pathPrefix + '../utils/formatting.js'); +const config = require(global.configFile); + +module.exports = { + payTransaction, + validateMerchantAccount, + invalidateMerchantAccount, + tokeniseCard +}; + +/** + * Worldpay doesn't allow invalidating merchant account access keys from the API, + * so we just always return success. The user would have to manualy invalidate + * their tokens + * + * @returns {promise} - A promise that resolves on success, or rejects on fail + */ +function invalidateMerchantAccount() { + return Q.resolve(); +} + +/** + * Makes a payment from customer to merchant using the appropriate acquirer + * + * @param {Object} client - the client making the payment + * @param {Object} device - the device making the payment + * @param {Object} data - various neccessary data + * @param {string} data.ClientKey - the client key required to decrypt the payment details + * @param {string} data.ipAddress - ipAddress of the client + * @param {Object} [data.cardDetails] - optional decrypted card details + * @param {Object} transaction - The transaction object with the payment info + * @param {Object} merchantInfo - Merchant account and address info + * @param {Object} customerInfo - Customer account and address info + * + * @returns {Promise} - Resolves to payment info, or rejects ERRORS value + */ +function payTransaction( + client, + device, + data, + transaction, + merchantInfo, + customerInfo +) { + /** + * Check that we have a payment method we can use + */ + if ( + (customerInfo.account.AccountType !== 'Credit/Debit Payment Card' && + customerInfo.account.AccountType !== 'Direct Credit/Debit Card Payment') || + customerInfo.account.AcquirerName === 'Demo' + ) { + return Q.reject({name: errors.INVALID_COMBINATION}); + } + + // + // Decryt the service key for the merchant + // + let merchantAccountDetails; + try { + merchantAccountDetails = encryption.decryptWorldpayMerchant(merchantInfo.account); + } catch (error) { + return Q.reject({name: errors.INVALID_MERCHANT_ACCOUNT_DETAILS}); + } + + // + // Get the details for the request + // + const path = 'orders'; + const getBodyP = getPayTransactionRequestBody(client, device, data, transaction, customerInfo); + + // + // Make the request + // + const requestP = getBodyP.then((body) => { + return Q.nfcall( + worldpay.worldpayFunction, + 'POST', + path, + merchantAccountDetails.worldpayServiceKey, + null, // No additional headers + body + ).catch((error) => { + // + // Convert errors here. Success is handled below + // The error may have more details. + // + debug('orders error:', error); + const errorCode = worldpayErrorToErrorCode(error); + return Q.reject({ + name: errorCode, + info: error.message + }); + }); + }); + + // + // Check everything worked + // + return Q.all([getBodyP, requestP]).spread((requestBody, response) => { + if (response.paymentStatus !== 'SUCCESS') { + return Q.reject({name: errors.PAYMENT_FAILED_UNSPECIFIED}); + } + + // + // Succeeded, so return the information we want to keep + // + const info = { + SaleReference: response.orderCode, + SaleAuthCode: response.customerOrderCode, + RiskScore: response.riskScore.value, + AVSResponse: '', + GatewayResponse: response.paymentStatus + }; + return Q.resolve(info); + }); +} + +/** + * Validates that a merchant account is valid for Worldpay. There is no specific + * function in the Worldpay API to check, so instead we make a trivial GET + * request and look for 401 error if the account key is wrong. + * + * @param {Object} account - the account to validate + * @returns {Promise} - resolves on succes or rejects with ERRORS value + */ +function validateMerchantAccount(account) { + // + // Decryt the service key for the merchant + // + const merchantAccountDetails = encryption.decryptWorldpayMerchant(account); + if (!merchantAccountDetails) { + return Q.reject({name: errors.INVALID_MERCHANT_ACCOUNT_DETAILS}); + } + + // + // We do a simple request for a non-existant order, and see what error code + // we get back. We are looking for unauthorised versus various "authorised + // but failed" errors. + // + return Q.nfcall( + worldpay.worldpayFunction, + 'GET', + '/orders/00000000-0000-0000-0000-000000000000', // Request a non-existent order + merchantAccountDetails.worldpayServiceKey, + null, // No additional headers + {} // No body + ) + .then(() => Q.resolve()) // Very surprising if we get here, but is still ok + .catch((error) => { + // + // Convert errors here. Success is handled below + // The error may have more details. + // + debug('validate accounts error:', error); + if (error.hasOwnProperty('customCode')) { + // + // Some error codes are expected and mean the token is ok + // + const pass = { + ORDER_NOT_FOUND: true, + INVALID_PAYMENT_DETAILS: true + }; + if (pass[error.customCode]) { + return Q.resolve(); + } + + // Other errors are converted and returned + const errorCode = worldpayErrorToErrorCode(error); + return Q.reject({ + name: errorCode, + info: error.message + }); + } else { + // Some network type error, so report it as service being down + return Q.reject({ + name: errors.ACQUIRER_DOWN, + info: error.message + }); + } + }); +} + +/** + * Builds the body for a Worldpay 'orders' request to pay a transaction + * + * @param {Object} client - the client making the payment + * @param {Object} device - the device the client is using + * @param {Object} data - various neccessary data + * @param {string} data.ClientKey - the client key required to decrypt the payment details + * @param {string} data.ipAddress - ipAddress of the client + * @param {Object} [data.cardDetails] - optional decrypted card details + * @param {Object} transaction - The transaction object with the payment info + * @param {Object} customerInfo - Customer account and address info + * + * @returns {Promise} - Resolves to the body for the request, or rejects ERRORS value + */ +function getPayTransactionRequestBody(client, device, data, transaction, customerInfo) { + // + // Decrypt the credit card and merchant account information + // + let cardDetails; + try { + cardDetails = + data.cardDetails || + encryption.decryptCard(customerInfo.account, data.ClientKey, client._id.toString()); + } catch (error) { + return Q.reject({name: errors.INVALID_CARD_DETAILS}); + } + + // + // Build the command we want to send + // + const requestBody = { + // + // Top level fields + // + orderType: 'ECOM', + currencyCode: 'GBP', + settlementCurrency: 'GBP', // In case merchant has enabled multiple currencies + amount: transaction.TotalAmount, + customerOrderCode: transaction._id.toString(), + shopperEmailAddress: client.ClientName, + + orderDescription: getOrderDescription(transaction), + name: getCustomerName(client), + + // + // Set the delivery address to be the same as the billing address as + // that is the most likely to be associated with the card. + // + billingAddress: getWorldpayAddress(client, customerInfo.address, false), + deliveryAddress: getWorldpayAddress(client, customerInfo.address, true), + + // + // Set the payment method object + paymentMethod: getWorldpayPaymentMethod(customerInfo.account, cardDetails) + }; + + // + // Shopper IP and session ID are not available through the integration API + // + if (data.ipAddress) { + requestBody.shopperIpAddress = data.ipAddress; + } + if (device.SessionToken) { + requestBody.shopperSessionID = device.SessionToken; + } + return Q.resolve(requestBody); +} + +/** + * Builds the order description + * + * @param {Object} transaction - The transaction object + * @returns {string} - A description string + */ +function getOrderDescription(transaction) { + let desc = 'Bridge'; + if (transaction.MerchantComment !== '') { + desc += ': ' + transaction.MerchantComment; + } + return desc; +} + +/** + * Builds a customer's full name from the various parts of their name that we store + * + * @param {Object} client - the client object + * @returns {string} - the client's name to report to WorldPay + */ +function getCustomerName(client) { + const kyc = client.KYC[0]; + const parts = [ + kyc.Title, + kyc.FirstName, + kyc.MiddleNames, + kyc.LastName + ]; + + // Remove any parts that are undefined, then join the parts into a name + return _.compact(parts).join(' '); +} + +/** + * Format our addresses into the format required by Worldpay + * + * @param {Object} client - the client object (for first and last name) + * @param {Object} address - the address object to convert + * @param {boolean} includeName - true to include the name (e.g. delivery address) + * @returns {Object} - Worldpay format address object + */ +function getWorldpayAddress(client, address, includeName) { + // + // Most parts are hardcoded conversions + // + const wpAddress = { + postalCode: address.PostCode, + city: address.Town, + state: address.County, + countryCode: 'GB', + telephoneNumber: address.PhoneNumber + }; + + // + // Include name if requested + // + if (includeName) { + wpAddress.firstName = client.KYC[0].FirstName; + wpAddress.lastName = client.KYC[0].LastName; + } + + // + // Street addresses are variable length, and we have an optional building + // name / flat number. So make sure we put the right parts in the right + // place depending on what fields we have. + // + const parts = [ + address.BuildingNameFlat, + address.Address1, + address.Address2 + ]; + const setParts = _.compact(parts); + for (let i = 1; i <= setParts.length; ++i) { + wpAddress['address' + i] = setParts[i]; + } + + return wpAddress; +} + +/** + * Gets a Worldpay formatted payment method based on account and decrypted card details + * + * @param {Object} account - The account to pay from + * @param {Object} decryptedCardDetails - The decrypted card details from the account + * + * @returns {Object} - Worldpay formatted payment details + */ +function getWorldpayPaymentMethod(account, decryptedCardDetails) { + const paymentMethod = { + type: 'Card', + name: account.NameOnAccount, + expiryMonth: decryptedCardDetails.expiryMonth, + expiryYear: decryptedCardDetails.expiryYear, + cardNumber: decryptedCardDetails.cardNumber + + // start date an issue number are optional, Added below if they exist. + }; + + if (decryptedCardDetails.startMonth && decryptedCardDetails.startYear) { + paymentMethod.startYear = decryptedCardDetails.startYear; + paymentMethod.startMonth = decryptedCardDetails.startMonth; + } + + if (decryptedCardDetails.issueNumber) { + paymentMethod.issueNumber = decryptedCardDetails.issueNumber; + } + + return paymentMethod; +} + +/** + * Tokenises the card and returns some interesting information about it, along + * with the encrypted token (assuming the appropriate keys are provided). + * + * @param {Object} cardDetails - the card details to tokenise + * @param {string?} clientKey - the client Key (to encrypt the token) + * @param {string?} clientID - the client ID (to encrypt the token) + * @returns {Promise} - a promise for the successful tokenisation + */ +function tokeniseCard(cardDetails, clientKey, clientID) { + // + // Get the details for the request + // + const path = 'tokens'; + const body = getTokeniseCardRequestBody(cardDetails); + + return Q.nfcall( + worldpay.worldpayFunction, + 'POST', + path, + null, // No service key + null, // No additional headers + body + ).then( + (response) => { + return tokenisedResponseToCardDetails(response, clientKey, clientID); + }, + (err) => { + // + // If there was a communication error, convert it to the appropriate + // acquirers error code. + // + // Note that this is in the form where the error callback is the + // second parameter to then(), rather than the more common + // .then().catch() approach as we want to only handle errors from + // worlpayFunction, not any errors from tokenisedResponseToCardDetails() + // which are already correctly formatted and returned as promise rejections. + // + debug('tokenise card error:', err); + + const errorCode = worldpayErrorToErrorCode(err); + return Q.reject({ + name: errorCode, + info: err.message + }); + }); +} + +/** + * Gets the properly formatted body for sending to worldpay + * + * @param {Object} cardDetails - card details in the format of an AddCard request + * @returns {Object} - the body for the request + */ +function getTokeniseCardRequestBody(cardDetails) { + // + // Split up the card dates we (may) have + // + const startDate = formatting.splitCardDate(cardDetails.CardValidFrom); + const expiryDate = formatting.splitCardDate(cardDetails.CardExpiry); + + // + // Initialise the body with the required params + // + const body = { + reusable: true, + paymentMethod: { + name: cardDetails.NameOnAccount, + expiryMonth: expiryDate.month, + expiryYear: expiryDate.year, + cardNumber: cardDetails.CardPAN, + type: 'Card', + cvc: cardDetails.CVV + }, + clientKey: config.worldpayClientKey // Always use the Comcarde ClientKey + }; + + // + // Add the optional params + // + if (startDate) { + body.paymentMethod.startMonth = startDate.month; + body.paymentMethod.startYear = startDate.year; + } + + if (cardDetails.IssueNumber) { + body.paymentMethod.issueNumber = cardDetails.IssueNumber; + } + + return body; +} + +/** + * Convert the Worldpay response into the standard format for the database + * including the further details of the card. + * + * @param {Object} response - the worldpay response + * @param {string?} clientKey - the client Key (to encrypt the token) + * @param {string?} clientID - the client ID (to encrypt the token) + * @returns {Promise} - Promise for the formatted information, or rejects on error + */ +function tokenisedResponseToCardDetails(response, clientKey, clientID) { + // + // Encrypt the token or leave it blank if we don't have the keys + // + let encryptedToken = ''; + if (clientKey && clientID) { + encryptedToken = utils.encryptDataV3(response.token, clientKey, clientID); + } + if (_.isObject(encryptedToken)) { + // + // Some unexpected error when encrypting the token. + // + return Q.reject({ + name: errors.TOKEN_ENCRYPTION_FAILED, + info: String(encryptedToken.code) + ': ' + encryptedToken.message + }); + } + + const encryptedAcquirerMerchantID = utils.encryptDataV1(config.worldpayMerchantID); + if (_.isObject(encryptedAcquirerMerchantID)) { + // + // Some unexpected error when encrypting the token. + // + return Q.reject({ + name: errors.TOKEN_ENCRYPTION_FAILED, + info: String(encryptedAcquirerMerchantID.code) + ': ' + encryptedAcquirerMerchantID.message + }); + } + + const encryptedAcquirerCipher = utils.encryptDataV1(config.worldpayServiceKey); + if (_.isObject(encryptedAcquirerCipher)) { + // + // Some unexpected error when encrypting the token. + // + return Q.reject({ + name: errors.TOKEN_ENCRYPTION_FAILED, + info: String(encryptedAcquirerCipher.code) + ': ' + encryptedAcquirerCipher.message + }); + } + + const details = { + Token: encryptedToken || '', + AcquirerName: 'Worldpay', + AcquirerMerchantID: encryptedAcquirerMerchantID, + AcquirerCipher: encryptedAcquirerCipher, + VendorID: response.paymentMethod.cardIssuer, + VendorAccountName: response.paymentMethod.cardProductTypeDescNonContactless, + IconLocation: response.paymentMethod.cardType + '.png', + Details: { + IsCorporate: response.paymentMethod.cardSchemeType === 'corporate', + AccountClass: responseToAccountClass(response), + Type: utils.CardTypes[response.paymentMethod.cardType] || utils.CardTypes.UNKNOWN, + IssuerCountry: response.paymentMethod.countryCode + } + }; + + return Q.resolve(details); +} + +/** + * Gets the appropriate utils.AccountClass value for the card class. + * + * @param {Object} response - the response from worldpay tokenisation request + * @returns {string} - The appropriate member of utils.AccountClass + */ +function responseToAccountClass(response) { + switch (response.paymentMethod.cardClass) { + case 'credit': + return utils.AccountClass.CREDIT; + case 'debit': + return utils.AccountClass.DEBIT; + default: + // Special case for Maestro which are marked as "unknown" but should be debit + if (response.paymentMethod.cardType === 'MAESTRO') { + return utils.AccountClass.DEBIT; + } else { + return utils.AccountClass.UNKNOWN; + } + } +} + +/** + * Converts a worldpay error to one ouf our standard error codes from acquirer_errors.js + * + * @param {Object} error - the Worldpay error object + * @returns {string} - standard error string + */ +function worldpayErrorToErrorCode(error) { + if (error.hasOwnProperty('customCode')) { + // Other errors are converted and returned + const convert = { + // + // Validation errors + // + UNAUTHORIZED: errors.ACQUIRER_UNAUTHORIZED, + MERCHANT_DISABLED: errors.ACQUIRER_MERCHANT_DISABLED, + + // + // Other errors + // + BAD_REQUEST: errors.ACQUIRER_BAD_REQUEST, + TKN_EXPIRED: errors.ACQUIRER_TKN_EXPIRED, + ERROR_PARSING_JSON: errors.ACQUIRER_BAD_REQUEST, + MEDIA_TYPE_NOT_SUPPORTED: errors.ACQUIRER_BAD_REQUEST, + INTERNAL_SERVER_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR, + UNEXPECTED_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR, + API_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR, + INVALID_PAYMENT_DETAILS: errors.ACQUIRER_INVALID_PAYMENT_DETAILS + }; + return convert[error.customCode] || errors.ACQUIRER_UNKNOWN_ERROR; + } else { + // Some network type error, so report it as service being down + return errors.ACQUIRER_DOWN; + } +} diff --git a/node_server/utils/acquirers/worldpay_acquirer.spec.js b/node_server/utils/acquirers/worldpay_acquirer.spec.js new file mode 100644 index 0000000..b090b93 --- /dev/null +++ b/node_server/utils/acquirers/worldpay_acquirer.spec.js @@ -0,0 +1,357 @@ +/* globals describe, beforeEach, afterEach, it */ +/** + * Unit testing file for the worldpay_acquirer + */ + +/* eslint-disable import/no-unassigned-import */ +/* eslint-disable mocha/no-hooks-for-single-case */ +/* eslint-disable global-require */ +/* eslint-disable promise/always-return */ +/* eslint-disable no-throw-literal */ +/* eslint max-nested-callbacks: ["error", 99] */ + +'use strict'; +require('../../tools/test/testGlobals.js'); + +const _ = require('lodash'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); + +const expect = chai.expect; +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const utils = require(global.pathPrefix + 'utils.js'); +const worldpay = require(global.pathPrefix + 'worldpay.js'); +const wpAcquirer = require('./worldpay_acquirer.js'); + +const acqErrors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js'); + +/** + * Some constants and variables to help with tests + */ +const TEST_SERVICE_KEY = 'A Worldpay Key'; +const VALID_SERVICE_KEY = utils.encryptDataV1(TEST_SERVICE_KEY); +const INVALID_SERVICE_KEY = 'BROKEN'; + +const VOID_UUID = '00000000-0000-0000-0000-000000000000'; +const TRANSACTION_ID = '123456789abcdef0'; +const CLIENT_KEY = '012345678abcdef'; +const CLIENT_ID = '0123456789abcdef01234567'; +const TEST_CARD_NO = '5555555555554444'; +const VALID_CARD = utils.encryptDataV3(TEST_CARD_NO, CLIENT_KEY, CLIENT_ID); +const VALID_EXPIRY = utils.encryptDataV3('01-23', CLIENT_KEY, CLIENT_ID); +let account = null; + +describe('worldpay_acquirer', () => { + describe('validateMerchantAccount', () => { + afterEach(() => { + worldpay.worldpayFunction.restore(); + }); + + describe('basic successes', () => { + beforeEach(() => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, { + customCode: 'ORDER_NOT_FOUND' + }); + + account = { + AcquirerCipher: VALID_SERVICE_KEY + }; + }); + + it('should resolve ok', () => { + return expect(wpAcquirer.validateMerchantAccount(account)) + .to.eventually.be.fulfilled; + }); + + it('should call the worldpayFunction', () => { + return wpAcquirer.validateMerchantAccount(account).finally(() => { + return expect(worldpay.worldpayFunction).to.be.called; + }); + }); + + it('should make a GET request to /orders/', () => { + return wpAcquirer.validateMerchantAccount(account).finally(() => { + return expect(worldpay.worldpayFunction) + .to.be.calledWith('GET', '/orders/00000000-0000-0000-0000-000000000000'); + }); + }); + }); + + describe('broken merchant key', () => { + beforeEach(() => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, { + customCode: 'ORDER_NOT_FOUND' + }); + + account = { + AcquirerCipher: INVALID_SERVICE_KEY + }; + }); + + it('should reject with invalid merchant details', () => { + return expect(wpAcquirer.validateMerchantAccount(account)) + .to.eventually.be.rejectedWith({ + name: acqErrors.INVALID_MERCHANT_ACCOUNT_DETAILS + }); + }); + }); + + describe('invalid merchant key', () => { + beforeEach(() => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, { + customCode: 'UNAUTHORISED' + }); + + account = { + AcquirerCipher: VALID_SERVICE_KEY + }; + }); + + it('should be rejected as unauthorised', () => { + return expect(wpAcquirer.validateMerchantAccount(account)) + .to.eventually.be.rejectedWith({ + name: acqErrors.ACQUIRER_UNAUTHORIZED + }); + }); + }); + }); + + /** + * Tests for the payTransaction request + */ + describe('payTransaction', () => { + const DEFAULT_DATA = { + client: { + _id: CLIENT_ID, + ClientName: 'a@example.com', + KYC: [{ + FirstName: 'John', + LastName: 'Doe' + }] + }, + device: { + SessionToken: 'a session token' + }, + data: { + ClientKey: CLIENT_KEY, + ipAddress: '127.0.0.1' + }, + transaction: { + _id: TRANSACTION_ID, + TotalAmount: 123 + }, + merchantInfo: { + account: { + AcquirerName: 'worldpay', + AccountType: 'Credit/Debit Receiving Account', + AcquirerCipher: VALID_SERVICE_KEY + }, + address: {} + }, + customerInfo: { + account: { + AcquirerName: 'worldpay', + AccountType: 'Credit/Debit Payment Card', + CardPANEncrypted: VALID_CARD, + CardExpiryEncrypted: VALID_EXPIRY + }, + address: {} + } + }; + let testData; + + afterEach(() => { + worldpay.worldpayFunction.restore(); + }); + + describe('basic success', () => { + beforeEach(() => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, null, { + orderCode: VOID_UUID, + customerOrderCode: TRANSACTION_ID, + riskScore: { + value: 1 + }, + paymentStatus: 'SUCCESS' + }); + + testData = _.cloneDeep(DEFAULT_DATA); + }); + + it('should resolve ok with valid data', () => { + return expect( + wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ) + ).to.eventually.deep.equal({ + SaleReference: VOID_UUID, + SaleAuthCode: TRANSACTION_ID, + RiskScore: 1, + AVSResponse: '', + GatewayResponse: 'SUCCESS' + }); + }); + + it('should call the worldpayFunction once', () => { + return wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ).then(() => { + return expect(worldpay.worldpayFunction).to.be.calledOnce; + }); + }); + + it('should request a POST to "orders"', () => { + return wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ).then(() => { + return expect(worldpay.worldpayFunction).to.be.calledWith('POST', 'orders'); + }); + }); + + it('should pass the correct key and worldpay body', () => { + return wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ).then(() => { + return expect(worldpay.worldpayFunction).to.be.calledWith( + 'POST', + 'orders', + TEST_SERVICE_KEY, // Properly decrypted + null, // No additional headers + sinon.match({ + amount: 123, // Still in pennies + billingAddress: sinon.match.object, + deliveryAddress: sinon.match.object, + currencyCode: 'GBP', + name: 'John Doe', // Concatenated + orderDescription: sinon.match.string, + orderType: 'ECOM', + paymentMethod: sinon.match({ + cardNumber: TEST_CARD_NO, // Properly decrypted + expiryMonth: '01', // Decrypted && split + expiryYear: '2023' // Decrypted, split && formatted + }), + settlementCurrency: 'GBP', + shopperEmailAddress: sinon.match.string, + shopperIpAddress: sinon.match.string, + shopperSessionID: sinon.match.string + }) + ); + }); + }); + }); + + describe('basic failures', () => { + beforeEach(() => { + sinon.stub(worldpay, 'worldpayFunction') + .callsArgWith(5, null, { + orderCode: VOID_UUID, + customerOrderCode: TRANSACTION_ID, + riskScore: { + value: 1 + }, + paymentStatus: 'SUCCESS' + }); + + testData = _.cloneDeep(DEFAULT_DATA); + }); + + it('should reject demo cards', () => { + testData.customerInfo.account.AccountType = 'Demo'; + return expect( + wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ) + ).to.eventually.be.rejected; + }); + + it('should reject if cant decrypt card number', () => { + testData.customerInfo.account.CardPANEncrypted = 'NotValid'; + return expect( + wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ) + ).to.eventually.be.rejected; + }); + + it('should reject if card expiry is missing', () => { + delete testData.customerInfo.account.CardExpiryEncrypted; + return expect( + wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ) + ).to.eventually.be.rejected; + }); + + it('should reject if cant decrypt merchant service key', () => { + testData.merchantInfo.account.AcquirerCipher = 'NotValid'; + return expect( + wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ) + ).to.eventually.be.rejected; + }); + + it('should reject if not given a credit card', () => { + testData.customerInfo.account.AccountType = 'Credit/ Debit Receiving Account'; + return expect( + wpAcquirer.payTransaction( + testData.client, + testData.device, + testData.data, + testData.transaction, + testData.merchantInfo, + testData.customerInfo + ) + ).to.eventually.be.rejected; + }); + }); + }); +}); diff --git a/node_server/utils/adminNotifier.js b/node_server/utils/adminNotifier.js new file mode 100644 index 0000000..cf57b53 --- /dev/null +++ b/node_server/utils/adminNotifier.js @@ -0,0 +1,142 @@ +/** + * Support utilities for notifying the admin of various cases + */ +'use strict'; + +const Q = require('q'); +const _ = require('lodash'); +const mailer = require(global.pathPrefix + 'mailer.js'); +const templates = require(global.pathPrefix + '../utils/templates.js'); +var debug = require('debug')('utils:adminNotifier'); + +/** + * Exports from this module + */ +module.exports = { + notifyIdentityCheckIssue: notifyIdentityCheckIssue, + notifyCredits: notifyCredits +}; + +/** + * Address that all notifications should be sent to + */ +const NOTIFICATION_EMAIL = 'admin@comcarde.com'; + +/** + * Table of credit limits to send emails + */ +const SERVICES_TABLE = { + tracesmart: { + limit: 100, + lastReport: Number.MAX_VALUE, + reportStep: 10 + }, + txtlocal: { + limit: 100, + lastReport: Number.MAX_VALUE, + reportStep: 10 + } +}; + +/** + * Notifies admin that there is an identity check issue to investigate + * + * @param {Object} client - the client object (updated with the latest identify results) + * + *@return {Promise} - promise for the result of sending the email + */ +function notifyIdentityCheckIssue(client) { + const caller = 'notifyIdentityCheckIssue'; + // + // Get the email parameters + // + var params = { + ClientID: client.ClientID, + ProfileURL: client.KYC[0].ProfileURL + }; + + // + // Render the email + // + var htmlEmail = templates.render('adminNotifier/identity_check.pug', params); + var subject = 'Manual Identity Check Needed'; + + // + // Pass it to the mailer to send (wrapped in a Q.nfcall to turn it into + // a promise). + // When the promise completes, call any callback defined + // + return Q.nfcall(mailer.sendEmail, '', NOTIFICATION_EMAIL, subject, htmlEmail, caller); +} + +/** + * Used to notify the admin if credits for a service are getting low. + * The meaning of "getting low" is defined in here, so callers just call this + * every time, and this system sends or does not send an email as neccessary. + * + * @param {String} name - the name of the service the credits are for + * @param {Number} value - the number of credits remaining + * + * @return {Promise} - promise for the result of sending the notification + */ +function notifyCredits(name, value) { + debug('notifyCredits: ', name, value); + + let service = SERVICES_TABLE[name]; + if (_.isUndefined(service)) { + /** + * Use a default service that will always report - should get fixed quickly! + */ + service = { + limit: Number.MAX_VALUE, + lastReport: Number.MAX_VALUE, + reportStep: 0 + }; + } + + /** + * Check if we need to send a notification + */ + let nextReport = service.lastReport - service.reportStep; + if ( + value > service.limit || // Above threshold + (service.lastReport > value && value > nextReport)// Between reporting steps + ) { + /** + * Don't need to send a report + */ + return Q.resolve(); + } + + /** + * DO need to send a report + */ + const caller = 'notifyCredits'; + // + // Get the email parameters + // + var params = { + Service: name, + CreditsRemaining: value, + CreditsLimit: service.limit + }; + + // + // Render the email + // + var htmlEmail = templates.render('adminNotifier/credits_low.pug', params); + var subject = '[' + name + '] Low Credits!'; + + // + // Pass it to the mailer to send (wrapped in a Q.nfcall to turn it into + // a promise). + // When the promise completes, call any callback defined + // + return Q.nfcall(mailer.sendEmail, '', NOTIFICATION_EMAIL, subject, htmlEmail, caller) + .then(() => { + /** + * Successfully sent the email, so updated the value we last reported at + */ + service.lastReport = value; + }); +} diff --git a/node_server/utils/anon.js b/node_server/utils/anon.js new file mode 100644 index 0000000..fc0cc8c --- /dev/null +++ b/node_server/utils/anon.js @@ -0,0 +1,272 @@ +/** + * Support utilities for anonymising data + * + */ +'use strict'; + +const _ = require('lodash'); +const utils = require('../ComServe/utils.js'); + +module.exports = { + anonymisePhoneNumber, + anonymiseAccountNumber, + anonymiseSortCode, + anonymiseWorldpayServiceKey, + anonymiseCardPAN, + anonymiseMerchantID, + anonymiseAccount, + anonymiseDevice, + anonymiseAddress, + anonymiseKYC +}; + +/** + * Helper function to anonymise a phone number. This converts something like + * +441506592361 + * into + * +44 1*** ***361 + * + * @param {string} phoneNumber - the phone number string + * @returns {string} - the anonymised number + */ +function anonymisePhoneNumber(phoneNumber) { + let tempString; + + /** + * We display 8 digits, so make sure we aren't returning almost everything. + */ + if (phoneNumber.length > 10) { + tempString = + phoneNumber.substr(0, 3) + + ' ' + + phoneNumber.substr(3, 1) + + '*** ***' + + phoneNumber.substr(-3); + } else { + /** + * To short to be a good number, so just return an empty string (as if there was no number) + */ + tempString = ''; + } + + return tempString; +} + +/** + * Anonymises an account number which is passed as a string. It retains the last 3 characters + * and adds 5 stars at the beginning regardless of actual length. + * - AccountNumber 12345678 => *****678 + * + * @type {Function} anonymiseAccountNumber + * @param {!string} accountNumber - Expected input is an 8 digit string. + * @returns {!string} Anonymised account number. + */ +function anonymiseAccountNumber(accountNumber) { + if (!accountNumber) { + return ''; + } + return ('*****' + accountNumber.substr(-3)); +} + +/** + * Anonymises a sort code which is passed as a string. It retains the last 2 characters + * and adds 4 stars and dashes at the beginning regardless of actual length. + * - SortCode 12-34-56 => **-**-56 + * + * @type {Function} anonymiseSortCode + * @param {!string} sortCode - Expected input is an 8 character string. + * @returns {!string} Anonymised sort code number. + */ +function anonymiseSortCode(sortCode) { + if (!sortCode) { + return ''; + } + return ('**-**-' + sortCode.substr(-2)); +} + +/** + * Anonymises a worldpay service key which is passed as a string. It retains the first 1 and last 4 characters, + * replaces all Hex characters with stars retaining dashes. + * - serviceKey T_S_713d2a60-a20b-4047-bc3a-3e863a11e414 => T_S_********-****-****-****-********e414 + * The function does not work with 8 or less characters so simply returns what it received. + * + * @type {Function} anonymiseWorldpayServiceKey + * @param {!string} serviceKey - Expected input is an 40 character string. + * @returns {!string} Anonymised card PAN. + */ +function anonymiseWorldpayServiceKey(serviceKey) { + if (!serviceKey) { + throw new Error('service key not set'); + } + if ((/^(?:T_S_|T_C_|L_S_|L_C_)[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(serviceKey))) { + let anonServiceKey = serviceKey.slice(0); + anonServiceKey = anonServiceKey.substr(0, 4) + '********-****-****-****-********' + anonServiceKey.substr(anonServiceKey.length - 4); + return anonServiceKey; + } else { + throw new Error('service key not consistent with a Worldpay service key'); + } +} + +/** + * Anonymises a card PAN which is passed as a string. It retains the first 1 and last 3 characters, + * adds stars and spaces after every quad in the middle regardless of actual length. + * - CardPAN 0123 4567 8901 2345=> 0*** **** **** *345 + * The function does not work with 4 or less characters so simply returns what it received. + * + * @type {Function} anonymiseCardPAN + * @param {!string} cardPAN - Expected input is an 8 character string. + * @returns {!string} Anonymised card PAN. + */ +function anonymiseCardPAN(cardPAN) { + if (!cardPAN) { + throw new Error('cardPAN not set'); + } + + const tempCardPAN = cardPAN.slice(0).replace(/ /g, ''); + + if (tempCardPAN.length < 5) { + return tempCardPAN; + } + + /** + * CardPAN is not always 16 digits. + */ + let anonPAN = tempCardPAN.substr(0, 1); + for (let xx = 1; xx < tempCardPAN.length; xx++) { + if ((xx % 4) === 0) { + anonPAN += ' '; + } + if (xx > (tempCardPAN.length - 4)) { + anonPAN += tempCardPAN.substr(xx, 1); + } else { + anonPAN += '*'; + } + } + + return anonPAN; +} + +/** + * Anonymises a merchant acquirer ID which is passed as a string. It retains the last 3 characters + * and adds 5 stars at the beginning regardless of actual length. + * - AcquirerMerchantID ABCDEFGH => *****FGH + * + * @type {Function} anonymiseMerchantID + * @param {!string} merchantID - Expected input is an 8 digit string. + * @returns {!string} Anonymised merchant ID. + */ +function anonymiseMerchantID(merchantID) { + if (!merchantID) { + return ''; + } + return ('*****' + merchantID.substr(-3)); +} + +/** + * Anonymises the given account by: + * 1. Deleting any fields that are not appropriate for this type of account + * 2. Anonymising any remaining fields. + * + * @param {Object} account - the account object to anonymise + * W074 cyclomatic complexity problem ignored on line. Suspected software error. + */ +function anonymiseAccount(account) { // jshint ignore:line + if (!account) { + return; + } + + const fields = ['AccountNumber', 'SortCode', 'CardPAN', 'AcquirerMerchantID']; + const keep = []; + + switch (account.AccountType) { + case utils.PaymentInstrumentType.CREDIT_DEBIT_PAYMENT_CARD: + keep.push('CardPAN'); + break; + case 'Bank Account': + keep.push('AccountNumber'); + keep.push('SortCode'); + break; + case 'Credit/Debit Receiving Account': + keep.push('AcquirerMerchantID'); + break; + default: + // Not a known type, so delete everything. + break; + } + + _.forEach( + fields, + (value) => { + if (keep.indexOf(value) === -1) { + // Not in the keep list, so delete + delete account[value]; + } + }); + + /** + * Now anonymise anything that's left + */ + if (account.AccountNumber !== undefined) { + account.AccountNumber = anonymiseAccountNumber(account.AccountNumber); + } + if (account.SortCode !== undefined) { + account.SortCode = anonymiseSortCode(account.SortCode); + } + if (account.AcquirerMerchantID !== undefined) { + account.AcquirerMerchantID = anonymiseMerchantID(account.AcquirerMerchantID); + } +} + +/** + * Anonymises the given device by: + * 1. Anonymising fields as follows: + * - DeviceNumber => +44 7*** ***234 + * + * @param {Object} device - the device object to anonymise + */ +function anonymiseDevice(device) { + if (!device) { + return; + } + + /** + * Anonymise fields + */ + if (device.DeviceNumber !== undefined) { + device.DeviceNumber = anonymisePhoneNumber(device.DeviceNumber); + } +} + +/** + * Anonymises the given address by: + * 1. Anonymising fields as follows: + * - PhoneNumber => +44 1*** ***234 + * + * @param {Object} address - the address object to anonymise + */ +function anonymiseAddress(address) { + if (!address) { + return; + } + + /** + * Anonymise fields + */ + if (address.PhoneNumber) { + address.PhoneNumber = anonymisePhoneNumber(address.PhoneNumber); + } +} + +/** + * Anonymises KYC data: + * 1. Remove the date of birth + * + * @param {Object} kyc - The object to be anonymised + */ +function anonymiseKYC(kyc) { + if (!kyc) { + return; + } + + kyc.DateOfBirth = null; +} diff --git a/node_server/utils/api_helpers.js b/node_server/utils/api_helpers.js new file mode 100644 index 0000000..10f5301 --- /dev/null +++ b/node_server/utils/api_helpers.js @@ -0,0 +1,31 @@ +/** + * Support utilities for handling the API + * + */ +'use strict'; + +var _ = require('lodash'); + +module.exports = { + renameFields: renameFields +}; + +/** + * Rename a field in the item by copying it to the new value and then deleting + * the old name. + * + * @param {Object | Object[]} items - The item or items to have the params renamed + * @param {Object} conversions - Key/values for the src name and dest name + */ +function renameFields(items, conversions) { + if (Array.isArray(items)) { + for (var i = 0; i < items.length; ++i) { + renameFields(items[i], conversions); + } + } else { + _.forEach(conversions, function(dest, src) { + items[dest] = items[src]; + delete items[src]; + }); + } +} diff --git a/node_server/utils/client/client.js b/node_server/utils/client/client.js new file mode 100644 index 0000000..7376e90 --- /dev/null +++ b/node_server/utils/client/client.js @@ -0,0 +1,445 @@ +/** + * Support utilities for the clients + */ +'use strict'; +const _ = require('lodash'); +const Q = require('q'); +const debug = require('debug')('utils:client'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var config = require(global.configFile); +var references = require(global.pathPrefix + '../utils/references.js'); +var diligence = require(global.pathPrefix + '../utils/diligence/diligence.js'); +var adminNotifier = require(global.pathPrefix + '../utils/adminNotifier.js'); + +const SETKYC_ERRORS = { + INVALID_PARAMETERS: 'BRIDGE: Invalid parameters to setKyc', + DOB_MISMATCH: 'BRIDGE: Date of birth doesnt match in setKyc', + UPDATE_FAILED: 'BRIDGE: Failed to update database in setKyc' +}; + +const SETKYC_RESPONSES = { + OK: 'BRIDGE: KYC complete', + WARNING_REFER: 'BRIDGE: Additional information required to verify identity', + WARNING_INTERNAL_CHECKS: 'BRIDGE: Additional internal checks required to verify identity' +}; + +module.exports = { + Client: Client, + generateEmailToken: generateEmailToken, + getCustomerInfo: getCustomerInfo, + setKyc: setKyc, + getDevicesInfo: getDevicesInfo, + + SETKYC_ERRORS: SETKYC_ERRORS, + SETKYC_RESPONSES: SETKYC_RESPONSES +}; + +/** + * Constructs a new client with appropriate default parameters. + * Note that this does not validate parameters - it is expected that the + * caller will have done all necessary validation. + * + * @class + * @param {String} email - email address + * @param {String} passwordHash - the users password, after hashing (as hex) + * @param {String} passwordSalt - the salt used in the hash (as hex) + * @param {String} operator - The account operator + */ +function Client(email, passwordHash, passwordSalt, operator) { + // + // Initialize a blank object + // + Object.assign(this, mainDB.blankClient()); + + // + // Update that object with the parameters passed in + // + this.ClientName = email; + this.KYC[0].ContactEmail = email; + this.Password = passwordHash; + this.ClientSalt = passwordSalt; + this.OperatorName = operator; + + // + // Get the base date that expiry values are based on + // + var baseDate = new Date(); + + // + // Set up tokens for email validation + // This token will be valid for 7 days + // + var emailToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + var emailTokenExpiry = new Date(baseDate.getTime()); + emailTokenExpiry.setDate(emailTokenExpiry.getDate() + 7); + + var token = generateEmailToken(); + this.EMailValidationToken = token.token; + this.EMailValidationTokenExpiry = token.expiry; + + // + // Initialize password expiry - 1 year expiry + // + var passwordExpiry = new Date(baseDate.getTime()); + passwordExpiry.setDate(passwordExpiry.getDate() + 365); + + this.PasswordManagement[0].PasswordExpiry = passwordExpiry; + this.PasswordManagement[0].PasswordLastReset = baseDate; +} + +/** + * Generates an email confirmation token and expiry date + * + * @returns {Object} - Returns an object with a token and an epiry + */ +function generateEmailToken() { + var emailToken = utils.randomCode(utils.fullAlphaNumeric, utils.tokenLength); + var emailTokenExpiry = new Date(); + emailTokenExpiry.setDate(emailTokenExpiry.getDate() + 7); + return { + token: emailToken, + expiry: emailTokenExpiry + }; +} + +/** + * Returns the appropriate info from the client to use as the customer in + * a transaction (or similar). The parmeters it returns are CustomerDisplayName, + * CustomerSubDisplayName, CustomerVATNo and CustomerSelfie + * + * @param {string} imageType - The type of image the user is using + * @param {object} client - The client object to get the info from + * + * @returns {Object | null} - the customer details (as above), or null on error + */ +function getCustomerInfo(imageType, client) { + // + // Initialise defaults for items that aren't appropriate + // + var result = { + CustomerDisplayName: '', + CustomerSubDisplayName: '', + CustomerImage: '', + CustomerVATNo: '' + }; + + // + // Get the correct details depending on the customer's defined image + // + switch (imageType) { + case 'Selfie': + result.CustomerDisplayName = client.DisplayName; + result.CustomerImage = client.Selfie; + break; + case 'defaultSelfie': + result.CustomerDisplayName = client.DisplayName; + result.CustomerImage = config.defaultSelfie; + break; + case 'CompanyLogo0': + result.CustomerDisplayName = client.Merchant[0].CompanyAlias; + result.CustomerSubDisplayName = client.Merchant[0].CompanySubName; + result.CustomerImage = client.Merchant[0].CompanyLogo; + if (client.Merchant[0].VATNo) { + result.CustomerVATNo = client.Merchant[0].VATNo; + } + break; + case 'defaultCompanyLogo0': + result.CustomerDisplayName = client.Merchant[0].CompanyAlias; + result.CustomerSubDisplayName = client.Merchant[0].CompanySubName; + result.CustomerImage = config.defaultCompanyLogo0; + if (client.Merchant[0].VATNo) { + result.CustomerVATNo = client.Merchant[0].VATNo; + } + break; + default: + // Something unknown so return null + return null; + } + return result; +} + +/** + * Updates the KYC information for a client, as well as attempting automatic + * identitiy verification from the given information. + * + * @param {Object} client - the client object from the database to update + * @param {Object} updates - the new information to update from. + * + * @returns {Promise} - Promise for the success or otherwise of the update + * Returned values from SETKYC_RESPONSES on success, + * or SETKYC_ERRORS, diligence.ERRORS or references.ERRORS + * on error. + */ +function setKyc(client, updates) { + + /** + * Check we've got all the required parameters + */ + if (!validateSetKycParams(client, updates)) { + return Q.reject(SETKYC_ERRORS.INVALID_PARAMETERS); + } + + /** + * Validate the client sent the correct DOB unless: + * - they haven't previously set a date of birth OR + * - they are currently in the REFER status of ID verification (i.e. they + * likely got something wrong, which could be DOB) + */ + let kyc = client.KYC[0]; + if ( + kyc.DateOfBirth !== '' && + kyc.DateOfBirth !== updates.DateOfBirth && + !utils.bitsAllSet(client.ClientStatus, utils.ClientRefer) + ) { + return Q.reject(SETKYC_ERRORS.DOB_MISMATCH); + } + + /** + * All ok, so update with the values from the request. + * We do this manually rather than in a database update because we want + * to verifiy the details before we commit them to the DB. + */ + kyc.Title = updates.Title; + kyc.FirstName = updates.FirstName; + kyc.LastName = updates.LastName; + kyc.DateOfBirth = updates.DateOfBirth; + kyc.ResidentialAddressID = updates.ResidentialAddressID; + kyc.Gender = updates.Gender; + if (updates.hasOwnProperty('MiddleNames')) { + // Set the middlename. Note: convert null into '' + kyc.MiddleNames = updates.MiddleNames || ''; + } + + /** + * Get the residential address + */ + let addressP = references.isValidAddressRef( + client.ClientID, + updates.ResidentialAddressID, + 'client.setKYC' + ); + + // + // Verify the person's identity with the newly updated data + // + var diligenceP = addressP.then((address) => { + return diligence.verifyIdentity(client, address); + }); + + // + // Update the record once we have verified the provided identity details + // + var updateP = diligenceP.then((diligenceResult) => { + // + // Build the query. The limits are: + // - Current user must be the owner (for security, to protect + // against Insecure Direct Object References). + // - DateOfBirth must match (or be unspeficied in the database, or the + // client's identity has not been verified) + // + var query = { + ClientID: client.ClientID, + $or: [ + {'KYC.0.DateOfBirth': updates.DateOfBirth}, + {'KYC.0.DateOfBirth': ''}, + {ClientStatus: {$bitsAllSet: utils.ClientRefer}} + ] + }; + + // + // Make sure the diligence result has the required defaults + // + _.defaults( + diligenceResult, + { + SmartScore: 998, + ID: '', + IKey: '', + ProfileURL: '' + } + ); + + // + // Build the update. This is slightly involved because the KYC is + // an array of subdocuments. We also build a new DisplayName from the + // FirstName + LastName. + // + var newValues = { + $inc: { + LastVersion: 1 + }, + $set: { + LastUpdate: new Date(), + DisplayName: updates.FirstName + ' ' + updates.LastName, + 'KYC.0.Title': updates.Title, + 'KYC.0.FirstName': updates.FirstName, + 'KYC.0.LastName': updates.LastName, + 'KYC.0.DateOfBirth': updates.DateOfBirth, + 'KYC.0.ResidentialAddressID': updates.ResidentialAddressID, + 'KYC.0.Gender': updates.Gender, + 'KYC.0.Smartscore': diligenceResult.Smartscore, + 'KYC.0.ID': diligenceResult.ID, + 'KYC.0.IKey': diligenceResult.IKey, + 'KYC.0.ProfileURL': diligenceResult.ProfileURL + } + }; + if (updates.hasOwnProperty('MiddleNames')) { + // Set the middlename. Note: convert null into '' + newValues.$set['KYC.0.MiddleNames'] = updates.MiddleNames || ''; + } + + // + // Work out the status bits we need to update in the client + // + let status = utils.ClientDetailsMask; + let response = SETKYC_RESPONSES.OK; + if (_.isArray(diligenceResult.Warnings)) { + for (let i = 0; i < diligenceResult.Warnings.length; ++i) { + // Don't report errors with bitwise operations + // jshint -W016 + switch (diligenceResult.Warnings[i]) { + case diligence.WARNINGS.REFER: + status |= utils.ClientRefer; + response = SETKYC_RESPONSES.WARNING_REFER; + break; + + case diligence.WARNINGS.PEPS: + status |= utils.ClientPeps; + break; + + case diligence.WARNINGS.SANCTIONS: + status |= utils.ClientSanctions; + response = SETKYC_RESPONSES.WARNING_INTERNAL_CHECKS; + break; + } + } + } + newValues.$bit = { + ClientStatus: { + or: status + } + }; + + // + // Build the options + // + var options = { + returnOriginal: false, // Need the updated document + upsert: false // Don't upsert if not found + }; + + // + // Make the request + // + return Q.ninvoke( + mainDB.collectionClient, + 'findOneAndUpdate', + query, + newValues, + options + ).then((result) => { + if (!result.ok || !result.value) { + // + // Nothing found - most likely some mistmatch in the search + // + return Q.reject(SETKYC_ERRORS.UPDATE_FAILED); + } else { + // + // Success (or success with warning). + // If the status is not a clean pass, then notify the admin + // + if (status !== utils.ClientDetailsMask) { + adminNotifier.notifyIdentityCheckIssue(result.value); + } + return Q.resolve(response); + } + }); + }); + + /** + * Return the result of the final promise, assuming they all pass + */ + return Q.all([addressP, diligenceP, updateP]).then((responses) => responses[2]); +} + +/** + * Validates that the parameters passed in are sufficient to update the KYC + * + * @param {Object} client - the client object to be updated + * @param {Object} updates - the update values + * @return {boolean} - true if the values are valid, false otherwise + */ +function validateSetKycParams(client, updates) { + const required = [ + 'Title', 'FirstName', 'LastName', 'DateOfBirth', 'Gender', 'ResidentialAddressID' + ]; + for (let i = 0; i < required.length; ++i) { + if (!_.isString(updates[required[i]])) { + return false; + } + } + + if (!_.isObject(client) || !_.isArray(client.KYC) || client.KYC.length <= 0) { + return false; + } + + return true; +} + +/** + * Gets information about the devices a client has. This function returns a + * promise for an object with two values: + * - hasDevices: does the client have any devices (in any state) + * - hasActiveDevice: does the client have at least one device that is fully active + * i.e. fully registered, not disabled, not barred, etc + * + * @param {string} clientID - the client ID we are interested in + * @returns {Promise} - promise for the status info + */ +function getDevicesInfo(clientID) { + const query = { + ClientID: clientID + }; + const projection = { + _id: 0, + DeviceStatus: 1 // Only need device status + }; + + let result = { + hasDevices: false, + hasActiveDevice: false + }; + + debug('Getting device info'); + + // + // Create an async/generator function to simplify looping over the results of find. + // This also lets us end early, and not force loading everything into an array + // + return Q.async(function*() { + let cursor = mainDB.collectionDevice.find(query, projection); + + while (yield cursor.hasNext()) { + debug(' -- hasNext'); + result.hasDevices = true; // We have at least one device + + let device = yield cursor.next(); + debug(' -- next:', device); + let status = device.DeviceStatus; + if ( + utils.bitsAllSet(status, utils.DeviceFullyRegistered) && + !utils.bitsAllSet(status, utils.DeviceSuspendedMask) && + !utils.bitsAllSet(status, utils.DeviceBarredMask) + ) { + result.hasActiveDevice = true; + + debug(' -- found active device:'); + break; + } + } + + debug(' - returning result'); + return result; + })(); +} diff --git a/node_server/utils/credentials.js b/node_server/utils/credentials.js new file mode 100644 index 0000000..4952700 --- /dev/null +++ b/node_server/utils/credentials.js @@ -0,0 +1,309 @@ +/** + * Support utilities for dealing with validating credentials (passwords and + * pin numbers), incrementing failed attempt counts, etc.. + */ +'use strict'; + +var Q = require('q'); +var crypto = require('crypto'); +var mongodb = require('mongodb'); +var templates = require(global.pathPrefix + '../utils/templates.js'); +var debug = require('debug')('utils:credentials'); +var config = require(global.configFile); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var mailer = require(global.pathPrefix + 'mailer.js'); +var utils = require(global.pathPrefix + 'utils.js'); +var hasherUtils = require(global.pathPrefix + '../utils/hashing.js'); + +const ERRORS = { + NOT_FOUND: 'Not found', + BARRED: 'Barred by Comcarde', + TOO_MANY_ATTEMPTS: 'Too many attempts', + CANT_UPDATE_ATTEMPTS_SUCCESS: 'Cant clear attempts count on success', + CANT_UPDATE_ATTEMPTS_FAIL: 'Cant update attempts count on fail', + CANT_SEND_WARNING_EMAIL: 'Cant send too many attempts warning email', + + DEVICE_NOT_VERIFIED: 'Device not verified - SMS not confirmed.', + DEVICE_NOT_AUTHORISED: 'Device not authorised - PIN not set.', + DEVICE_SUSPENDED: 'Device suspended by the user.' +}; + +module.exports = { + validatePassword: validatePassword, + validateRawPassword: validateRawPassword, + + ERRORS: ERRORS +}; + +/** + * Validates the email and password aganst the database values. It also: + * - Ensures they are not barred + * - Ensures they have not already exceeded the attempts limit + * - Increments the attempts limit on fail + * - Sends an email on reaching the attempts limit + * - Upgrades the password if neccessary + * + * @param {String} email - the email address + * @param {String} password - the password + * + * @returns {promise} - a promise that resolves on successful validation + */ +function validatePassword(email, password) { + // + // Setup the parameters + // + var query = { + ClientName: email + }; + + var collection = mainDB.collectionClient; + + // + // Object validity function + // + var checkStatusFunc = function isValidClient(client) { + if (utils.bitsAllSet(client.ClientStatus, utils.ClientBarredMask)) { + return ERRORS.BARRED; + } else { + return null; + } + }; + + // + // Password information + // + var passwordField = 'Password'; + var saltField = 'ClientSalt'; + + var maxAttempts = utils.passwordLockout; + + // + // Warning email options + // + var warningEmailOptions = { + template: 'account-locked', + param: 'ClientName', + to: 'ClientName', + subject: 'Bridge Account Locked' + }; + + // + // Run the validation + // + debug('- validating'); + return validate( + query, + collection, + checkStatusFunc, + password, + passwordField, + saltField, + maxAttempts, + warningEmailOptions + ); +} + +/** + * This function validates raw passwords - i.e. passwords that have not already + * had a single pass of sha-256 run on then. This is most useful for the + * web API because the devices run the SHA-256 interally before sending to + * the server. + * This runs the single pass of sha-256 then calls the main validatePassword, + * so all comments on that function apply here as well. + * + * @param {string} email - the users email address + * @param {string} password - the raw password + * + * @returns {promise} - a promise that resolves on successful validation. + */ +function validateRawPassword(email, password) { + var deferred = Q.defer(); + var promise = deferred.promise; + + var hasher = crypto.createHash('sha256'); + hasher.setEncoding('hex'); + hasher.end(password, 'utf8'); + + hasher.on('readable', function() { + var passwordHash = hasher.read(); + deferred.resolve(passwordHash); + }); + + return promise.then(function(passwordHash) { + return validatePassword(email, passwordHash); + }); +} + +/** + * Validates the given secret and saly against the database values. It also: + * - Checks the object passes the given checkStatusFunc (e.g. barred, etc.) + * - Ensures they have not already exceeded the attempts limit + * - Increments the attempts count on fail + * - Upgrades the secret in the database if neccessary + * + * @param {Object} query - the query used to find the object + * @param {Object} collection - the collection containing the objects + * @param {Function} checkStatus - function to check the status of the object (barred etc.) + * @param {String} secret - the secret to validate + * @param {String} secretField - field containing the secret (password, pin, etc.) + * @param {String} saltField - the field containing the salt + * @param {Int} maxAttempts - the maximum attempts allowed + * @param {Object} emailOptions - options for the sending of the warning email + */ +function validate(query, collection, checkStatus, secret, secretField, saltField, maxAttempts, emailOptions) { + // + // Step 1. Find the database object + // + var object = null; + var getObjectP = Q.nfcall(mainDB.findOneObject, collection, query, undefined, false) + .then(function(result) { + // Check we found an object + if (!result) { + return Q.reject(ERRORS.NOT_FOUND); + } else { + object = result; + return Q.resolve(result); + } + }); + + // + // Step 2. Validate the object. + // Check the pre- requisites: passes the checkStatus, + // and not too many attempts. + // Then check the password matches + // + var validObjectP = getObjectP.then(function(object) { + var checkResult = checkStatus(object); + if (checkResult) { + return Q.reject(checkResult); + } else if (object.LoginAttempts >= maxAttempts) { + return Q.reject(ERRORS.TOO_MANY_ATTEMPTS); + } else { + return hasherUtils.verifyHash( + secret, + object[secretField], + object[saltField], + 2 // TODO: make this a config item + ); + } + }); + + // + // Step 3. Check the results of the verifyHash + // + var validResultsP = validObjectP + .then(function(validity) { + // + // Succeeded so reset the attempts flag + // + var update = { + $set: {LoginAttempts: 0} + }; + + // + // If we were given an updated password hash, then also update that + // + if (validity !== null) { + update.$set[secretField] = validity.hash; + update.$set[saltField] = validity.salt; + update.$set.LastUpdate = new Date(); + update.$inc = {LastVersion: 1}; + } + + return Q.nfcall( + mainDB.updateObject, + collection, + query, + update, + undefined, + false) + .catch(function(result) { + return Q.reject(ERRORS.CANT_UPDATE_SUCCESS); + }); + + }) + .catch(function(error) { + debug('Failed validResults', error); + // + // Failed. If this failed because the password was wrong then + // we need to update the attempts count. Other failures are + // just returned as is. + // + if (error !== hasherUtils.ERRORS.NO_MATCH) { + return Q.reject(error); + } + + // + // It is a password failure, so update the LoginAttempts + // We also request the updated doc be returned so we can check if + // we need to send the "too many login fails" warning email + // + var update = { + $inc: {LoginAttempts: 1}, + $set: {LastUpdate: new Date()} + }; + var options = { + projection: {LoginAttempts: 1}, // Only need this field + upsert: false, // Don't add if it doesn't exist + returnOriginal: false // Want the updated doc + }; + + return Q.ninvoke( + collection, + 'findOneAndUpdate', + query, + update, + options + ).then(function(result) { + if (!result.value) { + // Didn't find anything to update + return Q.reject(ERRORS.CANT_UPDATE_ATTEMPTS_FAIL); + } else if (result.value.LoginAttempts === maxAttempts) { + // Need to send a warning email + // Set up the parameters then render the template + var emailParams = {}; + emailParams[emailOptions.param] = object[emailOptions.param]; + + var htmlEmail = templates.render( + emailOptions.template, + emailParams + ); + + var mode = config.isDevEnv ? 'Test' : 'Live'; + var to = object[emailOptions.to]; + // + // Then try to send it + // + debug('- sending email: ', mode, to); + return Q.nfcall( + mailer.sendEmail, + mode, + to, + emailOptions.subject, + htmlEmail, + 'credentials.validate' + ).then(function success() { + debug('- warning email sent ok'); + // Sent ok, so report too many attempts + return Q.reject(ERRORS.TOO_MANY_ATTEMPTS); + }, function fail(err) { + debug('- warning email send failed', err); + // Failed, so send the error + return Q.reject(ERRORS.CANT_SEND_WARNING_EMAIL); + }); + } else { + // Otherwise updated with the max attempts + return Q.reject(hasherUtils.ERRORS.NO_MATCH); + } + }); + }); + + return Q.all([getObjectP, validResultsP]) + .then(function(results) { + // Successfully validated. + // Q.all returns an array of results, but we just want the + // client object (availablefrom the first request) + // + return Q.resolve(results[0]); + }); +} diff --git a/node_server/utils/device/device.js b/node_server/utils/device/device.js new file mode 100644 index 0000000..784ef6a --- /dev/null +++ b/node_server/utils/device/device.js @@ -0,0 +1,38 @@ +/** + * Helpers functions for dealing with devices + */ +const _ = require('lodash'); + +const mainDB = require(global.pathPrefix + 'mainDB.js'); +const mainDBP = require(global.pathPrefix + 'mainDB-promises.js'); + +module.exports = { + archiveDevice +}; + +/** + * Archives a copy of the device object, after appropriate anonymisation + * + * @param {Object} device - The device to delete + * + * @returns {Promise} - Promise for the result of archiving the device + */ +function archiveDevice(device) { + /** + * Store the old object _id as DeviceIndex + */ + const archivedDevice = _.clone(device); + + archivedDevice.DeviceIndex = device._id.toString(); + delete archivedDevice._id; + archivedDevice.DeviceAuthorisation = ''; + archivedDevice.DeviceSalt = ''; + archivedDevice.CurrentHMAC = ''; + archivedDevice.PendingHMAC = ''; + archivedDevice.LastUpdate = new Date(); + + /** + * Write the object to the Archive. + */ + return mainDBP.addObject(mainDB.collectionDeviceArchive, archivedDevice, undefined, false); +} diff --git a/node_server/utils/device/specs/device.spec.js b/node_server/utils/device/specs/device.spec.js new file mode 100644 index 0000000..ba6b69e --- /dev/null +++ b/node_server/utils/device/specs/device.spec.js @@ -0,0 +1,89 @@ +/** + * Unit testing file for device utils + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../../tools/test/testGlobals.js'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const device = rewire('../device.js'); + +const mainDBStub = device.__get__('mainDB'); +const mainDBPStub = device.__get__('mainDBP'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +/** + * Define a sample Client and Device object to return + */ +describe('utils/device', () => { + describe('archive', () => { + const DEVICE_MONGO_ID = '01234567890abcdef'; + const DEVICE_FAKE = { + _id: DEVICE_MONGO_ID, + DeviceAuthorisation: 'SOME HASHED PASSWORD', + DeviceSalt: 'SOME SALT', + CurrentHMAC: 'SOME EXISTING HMAC', + PendingHMAC: 'SOME PENDING HMAC' + }; + + const EXPECTED_ARCHIVE_DEVICE = { + DeviceIndex: DEVICE_MONGO_ID, + DeviceAuthorisation: '', + DeviceSalt: '', + CurrentHMAC: '', + PendingHMAC: '', + LastUpdate: sinon.match.date + }; + + /** + * Stub the functions that will be used for the "happy path" + * The responses are specifically overriden below for testing the error cases + */ + beforeEach(() => { + sandbox.stub(mainDBPStub, 'addObject').resolves(); + mainDBStub.collectionDeviceArchive = 'Device Archive Collection'; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('archives a sanitised version of the device', () => { + const archiveP = device.archiveDevice(DEVICE_FAKE); + + return archiveP.then(() => + expect(mainDBPStub.addObject).to.have.been + .calledOnce + .calledWith( + mainDBStub.collectionDeviceArchive, + sinon.match(EXPECTED_ARCHIVE_DEVICE) + ) + ); + }); + + it('resolves when it is successfully archived', () => { + const archiveP = device.archiveDevice(DEVICE_FAKE); + + return expect(archiveP).to.eventually.be.fulfilled; + }); + + it('rejects if there is an error in the database', () => { + mainDBPStub.addObject.rejects(); + const archiveP = device.archiveDevice(DEVICE_FAKE); + + return expect(archiveP).to.eventually.be.rejected; + }); + }); +}); diff --git a/node_server/utils/diligence/diligence.js b/node_server/utils/diligence/diligence.js new file mode 100644 index 0000000..f8d4c98 --- /dev/null +++ b/node_server/utils/diligence/diligence.js @@ -0,0 +1,49 @@ +'use strict'; +/** + * Functions to interact with 3rd party, online due-diligence services for + * Anti-money laundering etc. + */ +var Q = require('q'); +var featureFlags = require(global.pathPrefix + '../utils/feature-flags/feature-flags.js'); +var errors = require(global.pathPrefix + '../utils/diligence/diligence_errors.js'); +var tracesmartIduAml = require(global.pathPrefix + '../utils/diligence/tracesmart-idu-aml.js'); + +const defaultProvider = 'tracesmart-idu-aml'; + +module.exports = { + ERRORS: errors.ERRORS, + WARNINGS: errors.WARNINGS, + verifyIdentity: verifyIdentity +}; + +/** + * Verifies the identity of the person using the provided information + * + * @param {Object} client - The client object for this person + * @param {Object} address - The address object for this _person_ (not credit card!) + * @param {Object} provider - The provider to use (or undefined for default provider) + * + * @return {Promise} - A promise for the completion of the verification + */ +function verifyIdentity(client, address, provider) { + // + // If this feature isn't enabled for this client, then return a success + // + if (!featureFlags.isEnabled('diligence', client)) { + return Q.resolve({ + Smartscore: 999 // Use an excesively high number to indicate didn't run + }); + } + + // + // If the feature is enabled then get the right provider and progress + // + provider = provider || defaultProvider; + switch (provider) { + case 'tracesmart-idu-aml': + return tracesmartIduAml.verifyIdentity(client, address); + + default: + return Q.reject({name: errors.ERRORS.UNKNOWN_PROVIDER}); + } +} diff --git a/node_server/utils/diligence/diligence_errors.js b/node_server/utils/diligence/diligence_errors.js new file mode 100644 index 0000000..ea9d8ba --- /dev/null +++ b/node_server/utils/diligence/diligence_errors.js @@ -0,0 +1,20 @@ +/** + * General error messages for client due diligence functions + */ +'use strict'; + +const ERRORS = { + UNKNOWN_PROVIDER: 'BRIDGE: UNKNOWN PROVIDER', + VERIFICATION_FAILED: 'BRIDGE: UNABLE TO VERIFICATION IDENTITY' +}; + +const WARNINGS = { + REFER: 'BRIDGE: MORE DETAILS NEEDED', + PEPS: 'BRIDGE: MATCHES PEPS', + SANCTIONS: 'BRIDGE: MATCHES SANCTIONS' +}; + +module.exports = { + ERRORS: ERRORS, + WARNINGS: WARNINGS +}; diff --git a/node_server/utils/diligence/tracesmart-idu-aml.js b/node_server/utils/diligence/tracesmart-idu-aml.js new file mode 100644 index 0000000..b257db1 --- /dev/null +++ b/node_server/utils/diligence/tracesmart-idu-aml.js @@ -0,0 +1,223 @@ +/** + * Uses the LexisNexis tracesmart IDU-AML SOAP api for verification + */ +'use strict'; + +var Q = require('q'); +var _ = require('lodash'); +var soap = require('soap'); +var debug = require('debug')('utils:diligence:tracesmart'); +var config = require(global.configFile); +var errors = require(global.pathPrefix + '../utils/diligence/diligence_errors.js'); +var adminNotifier = require(global.pathPrefix + '../utils/adminNotifier.js'); + +const Request = require('./tracesmart-idu-aml/request.js'); + +module.exports = { + verifyIdentity: verifyIdentity +}; + +/** + * Verifies the identity of the person using the provided information + * + * @param {Object} client - The client object for this person + * @param {Object} address - The address object for this _person_ (not credit card!) + * + * @return {Promise} - A promise for the completion of the verification + */ +function verifyIdentity(client, address) { + debug('Verify identity'); + + const soapClientP = Q.nfcall(soap.createClient, config.tracesmartIduAmlUrl); + + let request = new Request(client); + request.Person.applyClient(client); + request.Person.applyResidentialAddress(address); + + let responseP = soapClientP.then((soapClient) => { + debug('SOAP Client Found'); + + // + // The soap client splits out the members of the object into individual + // calls to the soap object (which we don't want). So we wrap our request + // in a wrapper so it passes on our mega-object. + // + const wrappedRequest = { + params: request.getRequest() //getTestParams() //request + }; + + debug('Send request to tracesmart:'); + + // Make the call + return Q.ninvoke( + soapClient, + 'IDUProcess', + request.getRequest() + ).then((result) => { + debug('RESPONSE OK:'); + return result[0]; + }).catch((err) => { + debug('ERROR: ', err); + return err; + }); + }); + + var convertP = responseP.then((response) => tidyResponse(response)); + + // + // Look at the results and decide if they are a pass or fail. + // Very basic criteria for now. + // + var resultP = convertP.then((converted) => { + if ( + _.isObject(converted) && + _.isObject(converted.Results) && + _.isObject(converted.Results.Summary) && + converted.Results.Summary.ResultText !== 'FAIL' + ) { + let response = { + Smartscore: converted.Results.Summary.Smartscore, + ID: converted.Results.Summary.ID, + IKey: converted.Results.Summary.IKey, + ProfileURL: converted.Results.Summary.ProfileURL, + Warnings: [] + }; + if (converted.Results.Summary.ResultText === 'REFER') { + response.Warnings.push(errors.WARNINGS.REFER); + } + if (_.isArray(converted.Results.Sanction) && converted.Results.Sanction.length) { + response.Warnings.push(errors.WARNINGS.PEPS); + + /** + * The items in the `Sanction` field may be PEPs or Sanctions. + * If they are sanctions this is a higher level issue + */ + for (let i = 0; i < converted.Results.Sanction.length; ++i) { + if (converted.Results.Sanction[i].Type === 'SANCTION') { + response.Warnings.push(errors.WARNINGS.SANCTIONS); + break; // Only need to find one to add the status + } + } + } + + debug('Result: ', JSON.stringify(converted)); + + /** + * Potentially notify if credits are running out + */ + adminNotifier.notifyCredits('tracesmart', converted.Results.Summary.Credits); + + return Q.resolve(response); + } else { + // + // Treat everything else as a fail for now + // + return Q.reject({ + name: errors.ERRORS.VERIFICATION_FAILED + }); + } + }); + + return resultP; +} + +/** + * This function tidies up a SOAP response to be a simpler JS object without all + * the SOAP related attributes. + * + * @example + * + * SOAP response of: + * + * { + * "Status": { + * "attributes": { + * "xsi:type": "xsd:boolean" + * }, + * "$value": "true" + * }, + * "ID": { + * "attributes": { + * "xsi:type": "xsd:string" + * }, + * "$value": "1234567890" + * }, + * "Smartscore": { + * "attributes": { + * "xsi:type": "xsd:int" + * }, + * "$value": "55" + * } + * } + * + * is transformed to: + * + * { + * "Status": true, + * "ID": "1234567890", + * "Smartscore": 55 + * } + * + * Note that types are applied as appropriate for the related SOAP type attribute. + * + * @param {Object} response - The soap response from the tracesmart interface + * @returns {any} - The simplified response + */ +function tidyResponse(response) { + let tidied = null; + let type = null; + if (_.isObject(response.attributes)) { + type = response.attributes['xsi:type']; + } + + // + // If this is a basic type then we just return the $value (which may not exist) + // + if (_.startsWith(type, 'xsd:') && _.isUndefined(response.$value)) { + // + // Don't coerce basic types with an undefined $value. + // Note: all Basic types start with 'xsd:' + // + tidied = undefined; + } else if (type === 'xsd:string') { + tidied = '' + response.$value; // Coerce to string + } else if (type === 'xsd:boolean') { + tidied = !!(response.$value); // Coerce to boolean + } else if (type === 'xsd:int') { + tidied = +(response.$value); // Coerce to number + } else if (type === 'SOAP-ENC:Array') { + // + // Arrays are a bit special in that they only have one other key, and + // if the array is length 1, the related value won't actually be an array! + // + tidied = []; // Default to empty array + _.forOwn(response, function(value, key) { + if (key === 'attributes') { + return; // Do nothing with attributes + } else if (_.isArray(value)) { + // This is an actual array of values so iterate it and push + // them into our response + _.forEach(value, (arrayItem) => { + tidied.push(tidyResponse(arrayItem)); + }); + } else if (_.isObject(value)) { + // Length 1 arrays are not in an array, so just push this item in + tidied.push(tidyResponse(value)); + } + }); + } else { + // + // We assume it is an object and iterate through and tidy them + // + tidied = {}; // Default to empty object + _.forOwn(response, function(value, key) { + if (key === 'attributes') { + return; // Do nothing with attributes + } else { + tidied[key] = tidyResponse(value); + } + }); + } + + return tidied; +} diff --git a/node_server/utils/diligence/tracesmart-idu-aml/request.js b/node_server/utils/diligence/tracesmart-idu-aml/request.js new file mode 100644 index 0000000..39dec16 --- /dev/null +++ b/node_server/utils/diligence/tracesmart-idu-aml/request.js @@ -0,0 +1,48 @@ +'use strict'; + +const config = require(global.configFile); +const RequestIDU = require('./requestIDU.js'); +const RequestPerson = require('./requestPerson.js'); +const RequestServices = require('./requestServices.js'); + +module.exports = Request; + +/** + * Construct an overall Request object as required by the API. This is initialised + * with the username and password from the global config. It also constructs + * the various sub documents. + * WARNING: the caller MUST subsequently update the Person object with the + * required information that we want to validate. + * + * @constructor + * + * @param {Object} client - client object from the database + */ +function Request(client) { + this.data = {}; + this.data.Login = { + username: config.tracesmartIduAmlUsername, + password: config.tracesmartIduAmlPassword + }; + this.IDU = new RequestIDU(client); + this.Person = new RequestPerson(); + this.Services = new RequestServices(); +} + +/** + * Function to get the request in the format needed for use with the SOAP request. + * In particular, the params need to be wrapped in a wrapper param so that they + * don't get split into individual parameters to the soap function call. + * + * @returns {Object} - a wrapped object for the soap request + */ +Request.prototype.getRequest = function() { + return { + params: { + Login: this.data.Login, + IDU: this.IDU.data, + Person: this.Person.data, + Services: this.Services.data + } + }; +}; diff --git a/node_server/utils/diligence/tracesmart-idu-aml/requestIDU.js b/node_server/utils/diligence/tracesmart-idu-aml/requestIDU.js new file mode 100644 index 0000000..aeb42b3 --- /dev/null +++ b/node_server/utils/diligence/tracesmart-idu-aml/requestIDU.js @@ -0,0 +1,33 @@ +'use strict'; +const _ = require('lodash'); + +module.exports = RequestIDU; + +/** + * Construct a RequestIDU object as required by the API. This is initialised + * to empty strings, then updated based on the client object + * + * @constructor + * + * @param {Object} client - client object from the database + */ +function RequestIDU(client) { + this.data = {}; + /** + * `Reference` is OUR reference. Set it to the client ID so we can search + * for it later and find all searchs for this client. + */ + this.data.Reference = client.ClientID; + + /** + * `ID` and `IKey` allow us to ADD new information, but not modify previous + * information, to a search for a single person. At this time, we don't + * do any automated "further infomation" searching, so always set these to + * empty strings to do a new search. + */ + this.data.ID = ''; + this.data.IKey = ''; + + this.data.Scorecard = 'IDU Default'; + this.data.equifaxUsername = ''; +} diff --git a/node_server/utils/diligence/tracesmart-idu-aml/requestPerson.js b/node_server/utils/diligence/tracesmart-idu-aml/requestPerson.js new file mode 100644 index 0000000..0338c88 --- /dev/null +++ b/node_server/utils/diligence/tracesmart-idu-aml/requestPerson.js @@ -0,0 +1,155 @@ +'use strict'; + +module.exports = RequestPerson; + +/** + * Construct a RequestPerson object as required by the API. This is initialised + * to empty strings, then accessor functions update the person object + * + * @constructor + */ +function RequestPerson() { + this.data = {}; + // + // There are a very large number of fields to be initialised, so this declares + // their names in an array, then loops round that to set them to '' + // + var basicFields = [ + // Name + 'forename', 'middle', 'surname', 'gender', 'dob', + + // Subject address details + 'address1', 'address2', 'address3', 'address4', 'address5', 'address6', 'postcode', + + // Passport + 'passport1', 'passport2', 'passport3', 'passport4', 'passport5', 'passport6', + 'passport7', 'passport8', + + //Travel Visa + 'travelvisa1', 'travelvisa2', 'travelvisa3', 'travelvisa4', 'travelvisa5', 'travelvisa6', + 'travelvisa7', 'travelvisa8', 'travelvisa9', + + //ID Card + 'idcard1', 'idcard2', 'idcard3', 'idcard4', 'idcard5', 'idcard6', 'idcard7', + 'idcard8', 'idcard9', 'idcard10', + + // Driving Licence + 'drivinglicence1', 'drivinglicence2', 'drivinglicence3', + + // Card Number + 'cardnumber', 'cardtype', + + // NI + 'ni', + + // NHS + 'nhs', + + // Birth Details + 'bforename', 'bmiddle', 'bsurname', 'maiden', 'bdistrict', 'bcertificate', + + // Electricity Bill + 'mpannumber1', 'mpannumber2', 'mpannumber3', 'mpannumber4', + + // Bank Account + 'sortcode', 'accountnumber', + + // Marriage Details + 'msubjectforename', 'msubjectsurname', 'mpartnerforename', 'mpartnersurname', + 'mdate', 'mdistrict', 'mcertificate', + + // Poll Number Details + 'pollnumber', + + // Email Details + 'email', 'email2', + + // Document Authentication Details + 'docfront', 'docback', 'docsize', + + // One Time Password Details + 'landline1', 'landline2', 'mobile1', 'mobile2', + 'otplandline1', 'otplandline2', 'otpmobile1', 'otpmobile2' + ]; + for (let i = 0; i < basicFields.length; ++i) { + this.data[basicFields[i]] = ''; + } + + // + // CardAVS is a special case as it is a nested object + // + this.data.cardavs = { + CardType: '', + CardHolder: '', + CardNumber: '', + CardStart: '', + CardExpire: '', + CV2: '', + IssueNumber: '', + CardAddress: { + Address1: '', + Address2: '', + Address3: '', + Address4: '', + Address5: '', + Postcode: '', + DPS: '' + } + }; +} + +// +// Functions to set the various parts of the request from data we have +// + +/** + * Applies the values from a client's KYC object to the RequestPerson object + * + * @param {Object} client - Client information from the database + */ +RequestPerson.prototype.applyClient = function(client) { + const kyc = client.KYC[0]; + this.data.forename = kyc.FirstName || ''; + this.data.middle = kyc.MiddleNames || ''; + this.data.surname = kyc.LastName || ''; + this.data.gender = kyc.Gender || ''; + this.data.dob = kyc.DateOfBirth || ''; +}; + +/** + * Applies the given address as the client's residential address details. + * + * @param {Object} address - the address details + */ +RequestPerson.prototype.applyResidentialAddress = function(address) { + // + // Need to format the address into the format they want: the printed format + // from the PAF programmers guide + // + let addressList = []; + const addressProps = ['BuildingNameFlat', 'Address1', 'Address2', 'Town']; + for (let i = 0; i < addressProps.length; ++i) { + const item = address[addressProps[i]]; + if (item) { + addressList.push(item); + } + } + + // + // Assign the parts we have to the address lines we have. + // Note the names of the params are 1-based: address1...address6 + // + for (let i = 1; i <= addressList.length; ++i) { + this.data['address' + i] = addressList[i - 1]; + } + // + // And fill in the rest of the lines with blanks + for (let i = addressList.length + 1; i <= 6; ++i) { + this.data['address' + i] = ''; + } + + // + // Finally, add the postcode + // + this.data.postcode = address.PostCode; +}; diff --git a/node_server/utils/diligence/tracesmart-idu-aml/requestServices.js b/node_server/utils/diligence/tracesmart-idu-aml/requestServices.js new file mode 100644 index 0000000..61afff1 --- /dev/null +++ b/node_server/utils/diligence/tracesmart-idu-aml/requestServices.js @@ -0,0 +1,58 @@ +'use strict'; +module.exports = RequestServices; + +/** + * Construct a RequestService object as required by the API. This is initialised + * with the services we need for customer due diligence + * + * @constructor + */ +function RequestServices() { + this.data = {}; + // Enable minimum services required for AML + const enabled = [ + 'address', + 'deathscreen', + 'dob', + 'sanction', + 'insolvency', + 'crediva', + 'ccj' + ]; + const disabled = [ + 'passport', + 'driving', + 'birth', + 'smartlink', + 'ni', + 'nhs', + 'cardavs', + 'cardnumber', + 'mpan', + 'bankmatch', + 'creditactive', + 'travelvisa', + 'idcard', + 'bankmatchlive', + 'companydirector', + 'searchactivity', + 'noticeofcorrection', + 'prs', + 'marriage', + 'pollnumber', + 'onlineprofile', + 'age', + 'docauth', + 'onetimepassword' + ]; + + // + // Set the disabled first, so that enabled overwrite in case of error + // + disabled.forEach((item) => { + this.data[item] = false; + }, this); + enabled.forEach((item) => { + this.data[item] = true; + }, this); +} diff --git a/node_server/utils/encryption.js b/node_server/utils/encryption.js new file mode 100644 index 0000000..fbfabad --- /dev/null +++ b/node_server/utils/encryption.js @@ -0,0 +1,299 @@ +/* eslint-disable complexity */ +/* eslint-disable lodash/prefer-lodash-typecheck */ +/** + * @fileOverview Support for encryption and decryption of account details + */ +'use strict'; + +const _ = require('lodash'); + +const utils = require(global.pathPrefix + 'utils.js'); + +module.exports = { + decryptCard, + decryptWorldpayMerchant, + encryptCard, + encryptCardMaintainingAccount, + decryptCardMaintainingAccount +}; + +/** + * Decrypts a string + * + * @param {string} encrypted - the encrypted string + * @returns {?String} - decrypted string, or null if nothing to decrypt + * @throws {Object} - throws an exception on decryption failure + */ +function decryptIfExistsV1(encrypted) { + // + // Check if there is anything to decrypt + // + if (_.isUndefined(encrypted) || encrypted === '') { + return null; + } + + const decrypted = utils.decryptDataV1(encrypted); + if (_.isString(decrypted)) { + return decrypted; + } else { + throw decrypted; // Throw an exception for any errors. + } +} + +/** + * Decrypts a string + * + * @param {string} encrypted - the encrypted string + * @param {string} key - the key to decrypt with + * @param {string} userID - the ID of the user + * @returns {?String} - decrypted string, or null if nothing to decrypt + * @throws {Object} - throws an exception on decryption failure + */ +function decryptIfExistsV3(encrypted, key, userID) { + // + // Check if there is anything to decrypt + // + if (_.isUndefined(encrypted) || encrypted === '') { + return null; + } + + return utils.decryptDataV3(encrypted, key, userID); +} + +/** + * Encrypts a string + * + * @param {string} plainString - the encrypted string + * @param {string} key - the key to encrypt with + * @param {string} userID - the ID of the user + * @returns {?String} - encrypted string, or null if nothing to encrypt + * @throws {Object} - throws an exception on encryption failure + */ +function encryptIfExistsV3(plainString, key, userID) { + // + // Check if there is anything to encrypt + // + if (_.isUndefined(plainString) || plainString === '') { + return null; + } + + return utils.encryptDataV3(plainString, key, userID); +} + +/** + * This function encrypts the various card details as required and available. + * + * @param {Object} account - the account containing the details to encrypt + * @param {string} key - the key from the device + * @param {string} userID - the _id of the user as a string + * + * @returns {?Object} - an object with the decrypted details + * @throws {TypeError} - an error + */ +function encryptCard(account, key, userID) { + /** + * Encrypt and store the card details. + */ + let temp; + const encryptedCardDetails = {}; + + /** + * CardPAN + */ + temp = encryptIfExistsV3( + account.CardPanToBeEncrypted, + key, + userID); + if (!_.isString(temp)) { + throw new TypeError('Error when encrypting CardPAN.'); + } + encryptedCardDetails.CardPANEncrypted = temp; + + /** + * CardExpiry + */ + temp = encryptIfExistsV3( + account.CardExpiryToBeEncrypted, + key, + userID); + if (!_.isString(temp)) { + throw new TypeError('Error when encrypting CardExpiry.'); + } + encryptedCardDetails.CardExpiryEncrypted = temp; + + /** + * CardValidFrom + */ + if (account.CardValidFromToBeEncrypted && account.CardValidFromToBeEncrypted !== '') { + temp = utils.encryptDataV3( + account.CardValidFromToBeEncrypted, + key, + userID); + if (!_.isString(temp)) { + throw new TypeError('Error when encrypting CardValidFrom.'); + } + encryptedCardDetails.CardValidFromEncrypted = temp; + } + + /** + * IssueNumber + */ + if (account.IssueNumberToBeEncrypted) { + temp = utils.encryptDataV3( + account.IssueNumberToBeEncrypted.toString(), + key, + userID); + if (!_.isString(temp)) { + throw new TypeError('Error when encrypting IssueNumber.'); + } + encryptedCardDetails.IssueNumberEncrypted = temp; + } + return encryptedCardDetails; +} + +/** + * This function calls encrypt card, deletes unencrypted details and adds the encrypted details to the account object that was provided + * + * @param {Object} data - the data contaning an account which contains the details to encrypt + * @param {string} key - the key from the device + * @param {string} userID - the _id of the user as a string + * + * @returns {?Object} - an object with the decrypted details + * @throws {TypeError} - an error + */ +function encryptCardMaintainingAccount(data, key, userID) { + const clonedAccount = _.cloneDeep(data.Account); + const cardInfo = clonedAccount.CreditDebitCardInfo; + const encryptedDetails = encryptCard(cardInfo, key, userID); + const removeArray = ['CardPanToBeEncrypted', 'CardExpiryToBeEncrypted', 'IssueNumberToBeEncrypted', 'CardValidFromToBeEncrypted']; + + const temp = _.omit(cardInfo, removeArray); + + const encryptedCardInfo = _.defaults( + {}, + encryptedDetails, + temp + ); + clonedAccount.CreditDebitCardInfo = encryptedCardInfo; + data.Account = clonedAccount; + return data; +} + +/** + * This function calls decrypt card, deletes unencrypted details and adds the encrypted details to the account object that was provided + * + * @param {Object} account - the account containing the details to decrypt + * @param {string} key - the key from the device + * @param {string} userID - the _id of the user as a string + * + * @returns {?Object} - an object with the decrypted details or null + * @throws {TypeError} - an error + */ +function decryptCardMaintainingAccount(account, key, userID) { + const clonedAccount = _.cloneDeep(account); + const cardInfo = clonedAccount.CreditDebitCardInfo; + const decryptedDetails = decryptCard(cardInfo, key, userID); + if (decryptedDetails) { + const removeArray = ['CardExpiryEncrypted', 'CardPANEncrypted', 'IssueNumberEncrypted', 'CardValidFromEncrypted']; + const temp = _.omit(cardInfo, removeArray); + + const decryptedCardInfo = _.defaults( + {}, + decryptedDetails, + temp + ); + clonedAccount.CreditDebitCardInfo = decryptedCardInfo; + return clonedAccount; + } else { + return null; // decryption failed + } +} + +/** + * This function decrypts the various card details as required and available. + * + * @param {Object} account - the account containing the details to decrypt + * @param {string} key - the key from the device + * @param {string} userID - the _id of the user as a string + * + * @returns {?Object} - an object with the decrypted details or null + * @throws {TypeError} - an error + */ +function decryptCard(account, key, userID) { + const result = {}; + let dec = null; + + // + // Decrypt required fields. + // If a string is returned than the string is saved to the result + // If a specifc error is returned than null is returned (this helps higher level functions throw a specific error) + // If null or any other error is returned than an error is thrown + // + dec = decryptIfExistsV3(account.CardExpiryEncrypted, key, userID); + if (_.isString(dec)) { + result.expiryMonth = dec.substr(0, 2); + result.expiryYear = '20' + dec.substr(3, 2); + } else if (dec && typeof _.isObject(dec) && dec.code === 9) { + return null; + } else { + throw new TypeError('Decryption Error'); + } + + dec = decryptIfExistsV3(account.CardPANEncrypted, key, userID); + if (_.isString(dec)) { + result.cardNumber = dec; + } else if (dec && typeof _.isObject(dec) && dec.code === 9) { + return null; + } else { + throw new TypeError('Decryption Error'); + } + + // + // Decrypt optional fields + // + dec = decryptIfExistsV3(account.IssueNumberEncrypted, key, userID); + if (_.isString(dec)) { + result.IssueNumber = parseInt(dec, 10); + } else if (dec && typeof _.isObject(dec) && dec.code === 9) { + return null; + } else if (dec !== null) { + throw new TypeError('Decryption Error'); + } + + dec = decryptIfExistsV3(account.CardValidFromEncrypted, key, userID); + if (_.isString(dec)) { + result.startMonth = dec.substr(0, 2); + result.startYear = '20' + dec.substr(3, 2); + } else if (dec && typeof _.isObject(dec) && dec.code === 9) { + return null; + } else if (dec !== null) { + throw new TypeError('Decryption Error'); + } + return result; +} + +/** + * Decrypts the worldpay merchant info, if exists + * + * @param {Object} account - The account to get the data from + * + * @returns {?Object} - an object with the decrypted details or null on error + */ +function decryptWorldpayMerchant(account) { + const result = {}; + + try { + const dec = decryptIfExistsV1(account.AcquirerCipher); + if (_.isString(dec)) { + result.worldpayServiceKey = dec; + } else { + throw new TypeError('AcquirerCipher missing'); + } + } catch (error) { + // Decryption failed or fields are missing + return null; + } + + return result; +} + diff --git a/node_server/utils/feature-flags/feature-flags.js b/node_server/utils/feature-flags/feature-flags.js new file mode 100644 index 0000000..b13236c --- /dev/null +++ b/node_server/utils/feature-flags/feature-flags.js @@ -0,0 +1,57 @@ +/** + * Implements the functionality behind feature flags + */ +'use strict'; + +const flagsList = require('./flags-list.js'); +const _ = require('lodash'); + +module.exports = { + flagsList: flagsList, + + isEnabled: isEnabled +}; + +/** + * This function tests if the specified flag is enabled. + * At present, this is simply a check if the specified flag is in an array of + * featureFlags in the provided object. + * It can be expanded in the future to e.g. return true based on other params + * of the object such as email address, or randomly on ID. + * + * @param {String} flag - The flag to test for + * @param {Object} obj - The object to test if the flag is enabled + * @param {String[]} obj.FeatureFlags - Array of enabled flags for this obj + * + * @returns {boolean} - True if the feature is enabled + * + * @throws {Error} - Throws an Error if the flag is not in the declared + * list of valid flags as this likely indicates a typo + * in the code etc. Also throws if provided `obj` is + * not an object. + */ +function isEnabled(flag, obj) { + if (flagsList.indexOf(flag) === -1) { + throw new Error('Flag <' + flag + '> not declared. Check correct flag name in use.'); + } + + // + // Check that this is an object. Note that Arrays and Functions are also + // "objects" but not of the type we want. + // + if (!_.isObject(obj) || _.isArray(obj) || _.isFunction(obj)) { + throw new Error('Cannot test for flag as obj is not an object.'); + } + + if (!_.isUndefined(obj.FeatureFlags) && !_.isArray(obj.FeatureFlags)) { + throw new Error('obj.FeatureFlags must be undefined or an array.'); + } + + if (!_.isArray(obj.FeatureFlags)) { + return false; // No flags array is treated the same as flag no present + } else if (obj.FeatureFlags.indexOf(flag) === -1) { + return false; // Array of flags exists, but the flag isn't in it + } else { + return true; // Flag is in the list so the feature is enabled + } +} diff --git a/node_server/utils/feature-flags/feature-flags.spec.js b/node_server/utils/feature-flags/feature-flags.spec.js new file mode 100644 index 0000000..9e4b69c --- /dev/null +++ b/node_server/utils/feature-flags/feature-flags.spec.js @@ -0,0 +1,76 @@ +/* globals describe, beforeEach, it */ +/** + * Unit testing file for the feature flags + */ +'use strict'; +const expect = require('chai').expect; + +const featureFlags = require('./feature-flags.js'); + +describe('feature-flags', function() { + describe('defaults', function() { + it('should have `unit-test` flag', function() { + expect(featureFlags.flagsList).to.contain('unit-test'); + }); + }); + + // + // Test for all the cases that throw exceptions. + // NOTE: to catch the exception we must wrap the function in an anonymous + // function. Using ES6 arrow functions this just adds `() =>` to the call + // + describe('parameter verification', function() { + it('should throw for unspecified flag', function() { + expect(() => featureFlags.isEnabled('UNDECLARED FLAG NAME', {})) + .to.throw(/Flag not declared/); + }); + + it('should throw if not passed a second param', function() { + expect(() => featureFlags.isEnabled('unit-test')) + .to.throw(/Cannot test for flag as obj is not an object./); + }); + + it('should throw if passed an array rather than object as the second param', function() { + expect(() => featureFlags.isEnabled('unit-test', [])) + .to.throw(/Cannot test for flag as obj is not an object./); + }); + + it('should throw if obj.FeatureFlags exists, bit is not an array', function() { + expect(() => featureFlags.isEnabled('unit-test', {FeatureFlags: 'A string'})) + .to.throw(/obj.FeatureFlags must be undefined or an array./); + }); + }); + + describe('isEnabled', function() { + it('should return true if the flag is enabled', function() { + const objWithFlag = { + FeatureFlags: ['unit-test'] + }; + expect(featureFlags.isEnabled('unit-test', objWithFlag)) + .to.equal(true); + }); + + it('should return false if the flag is not enabled, but others are', function() { + const objWithOtherFlag = { + FeatureFlags: ['something else'] + }; + expect(featureFlags.isEnabled('unit-test', objWithOtherFlag)) + .to.equal(false); + }); + + it('should return false if no flags are enabled', function() { + const objEmptyFlagsArray = { + FeatureFlags: [] + }; + expect(featureFlags.isEnabled('unit-test', objEmptyFlagsArray)) + .to.equal(false); + }); + + it('should return false if FeatureFlags is undefined', function() { + const objWithoutFlags = { + }; + expect(featureFlags.isEnabled('unit-test', objWithoutFlags)) + .to.equal(false); + }); + }); +}); diff --git a/node_server/utils/feature-flags/flags-list.js b/node_server/utils/feature-flags/flags-list.js new file mode 100644 index 0000000..c62db8b --- /dev/null +++ b/node_server/utils/feature-flags/flags-list.js @@ -0,0 +1,14 @@ +/** + * This file defines the list of feature flags available in the app. + */ +'use strict'; + +module.exports = [ + 'unit-test', // Flag used for unit testing. DO NOT REMOVE + 'cardpayments', // Allow card based payments for this client. + 'diligence', // Enables customer due diligence verification + 'tokens', // Allow tokens for the integration API to be created and used + 'invoices', // Allow invoices functionality. + 'messages', // The messaging centre is enabled and should be shown. Login messages should be shown regardless. + 'vat' // Allow VAT functionality +]; diff --git a/node_server/utils/formatting.js b/node_server/utils/formatting.js new file mode 100644 index 0000000..21fd83f --- /dev/null +++ b/node_server/utils/formatting.js @@ -0,0 +1,64 @@ +/** + * Support utilities for formatting values for display/emails + * + */ +'use strict'; +const _ = require('lodash'); +var url = require('url'); +var config = require(global.configFile); + +module.exports = { + formatMoney: formatMoney, + formatPortalUrl: formatPortalUrl, + splitCardDate: splitCardDate +}; + +/** + * Formats an amount in pence into pounds and pence for display to a user. + * + * @param {integer} amountInPence - The value to format (in pence, as in the db) + * + * @returns {string} - A formatted string for the amount in pounds & pence + */ +function formatMoney(amountInPence) { + return '£' + (amountInPence / 100).toFixed(2); +} + +/** + * Formats a URL that points to the given path on the portal for the current + * host. It will append the pathname and any query parameters given. + * `pathname` and `query` are as used by `url.format()` + * + * @param {String} pathname - the desired path relative to the portal + * @param {Object} query - key/value pairs for query parameters + * + * @returns {String} - formatted URL string for the full path + */ +function formatPortalUrl(pathname, query) { + return url.format({ + protocol: 'https', + host: config.webconsole.host, + pathname: config.webconsole.path + pathname, + query: query + }); +} + +/** + * Splits a card date in "MM-YY" format into an object of the form: + * { + * month: "" + * year: "20" + * } + * + * @param {String} cardDate - the date to split + * @return {Object|null} - the split date object or null on error + */ +function splitCardDate(cardDate) { + let result = null; + if (_.isString(cardDate)) { + result = {}; + result.month = cardDate.substr(0, 2); + result.year = '20' + cardDate.substr(3, 2); + } + return result; +} diff --git a/node_server/utils/hashing.js b/node_server/utils/hashing.js new file mode 100644 index 0000000..1c51dec --- /dev/null +++ b/node_server/utils/hashing.js @@ -0,0 +1,251 @@ +/** + * Support utilities for dealing with hashing and comparison of pins and + * passwords. + * + * Password hashes in the db are formatted as: + * :: + * where is the version of the hashing scheme in use for this password, + * and is the actual hash. + * + * There is an exception to this for the very first scheme which has no + * :: on the front. + */ +'use strict'; + +var Q = require('q'); +var crypto = require('crypto'); + +const ERRORS = { + UNKNOWN_ALGO: 'Unknown hash algorithm', + HASH_FAILED: 'Failed to generate hash', // Likely unsupported digest algorithm + NO_MATCH: 'Regenerated hash not a match', + SALT_FAILED: 'Failed to generate a new salt' +}; + +const V2_ALGO = { + proto: 'sha256', + iterations: 10000, + keylength: 32, + saltLength: 32 +}; + +module.exports = { + generateHash: generateHash, + verifyHash: verifyHash, + + regenerateHash: regenerateHash, + + ERRORS: ERRORS +}; + +/** + * Function to generate the hash for a given password using the specified + * version of the alogorithm. This promise returns the following format: + * { + * salt: , + * hash: :: + * } + * format + * + * @param {Integer} version - The hashing algorithm version to use + * @param {String} password - The password to be hashed + * + * @returns {Promise} - Promise that resolves to the new salt & hash + */ +function generateHash(version, password) { + if (version < 2) { + // + // Don't support making new hashes for version 1 + // + return Q.reject(ERRORS.UNKNOWN_ALGO); + } + + // + // Step 1. Find the length of salt to generate + // + var saltLength = -1; + if (version === 2) { + saltLength = V2_ALGO.saltLength; + } + // FUTURE: any new algorithms should be added here + + if (saltLength < 0) { + return Q.reject(ERRORS.UNKNOWN_ALGO); + } + + // + // Step 2. Make the new salt + // + var saltPromise = Q.nfcall(crypto.randomBytes, saltLength) + .then(function(salt) { + // + // We want the salt as a hex string, not as a buffer + // + return Q.resolve(salt.toString('hex')); + }) + .catch(function(err) { + return Q.reject(ERRORS.SALT_FAILED); + }); + + // + // Step 3: Use the new salt to generate a new hash + // + var hashPromise = saltPromise.then(function(salt) { + return regenerateHash(version, password, salt); + }); + + // + // Run both promises, and return the result. Note tha error results will + // be automatically recieved by the caller + // + return Q.all([saltPromise, hashPromise]) + .then(function(results) { + // + // All promises completed, so send the results back + // + return Q.resolve({ + salt: results[0], // Result of 1st promise + hash: results[1] // Results of 2nd promise + }); + }); +} + +/** + * Function to verify if the input password matches the values from the database. + * + * @param {String} inputPassword - The incoming password/pin from the client/app + * @param {String} dbHash - The hash string stored in the database (inc version) + * @param {String} dbSalt - The salt in the database (so we can replicate the hash) + * @param {Integer} latestVersion - The lastest hash version (check if we need to update) + * + * @returns {Promise} - A promise for the result of the test. If it + * resolves + */ +function verifyHash(inputPassword, dbHash, dbSalt, latestVersion) { + // + // Step 1, split the dbHash so we know what version it is + // + var hashInfo = getHashInfo(dbHash); + if (hashInfo.version === -1) { + return Q.reject(ERRORS.UNKNOWN_ALGO); + } + + // + // Step 2. Regenerate the hash from the input password and the salt, and + // make sure it matches + // + var regenPromise = regenerateHash(hashInfo.version, inputPassword, dbSalt) + .then(function(regenHash) { + if (regenHash === dbHash) { + return Q.resolve(); + } else { + return Q.reject(ERRORS.NO_MATCH); + } + }); + + // + // Step 3. If we are not using the latest hash version, then update the + // hash + // + var newHashPromise; + if (hashInfo.version !== latestVersion) { + newHashPromise = regenPromise.then(function() { + return generateHash(latestVersion, inputPassword); + }); + } else { + newHashPromise = Q.resolve(null); + } + + return Q.all([regenPromise, newHashPromise]) + .then(function(results) { + // + // Success. We only care about the newHashPromise, so return + // just that reesult. + // + return Q.resolve(results[1]); + }); +} + +/** + * Generates a hash from the given hash code version, password, and salt. + * The hash generated is in the same format as would be in the database (e.g. + * it includes the algorithm version pre-pended to the hash (if appropriate). + * + * @param {Integer} version - The algorithm version + * @param {String} password - The password to hash + * @param {String} salt - The salt to use in the hash (in hex) + * + * @returns {Promise} - Resolves to the hashed password, or rejects with error + */ +function regenerateHash(version, password, salt) { + // + // Version 1 was trivial, and didn't actually do any further encoding! + // So just return the same password + // + if (version === 1) { + return Q.resolve(password); + } + + // + // Version 2 is PBKDF2 with defined parameters + // + if (version === 2) { + return Q.nfcall( + crypto.pbkdf2, + password, + salt, + V2_ALGO.iterations, + V2_ALGO.keylength, + V2_ALGO.proto + ) + .then(function(hash) { + // + // Hash function passed, so add on the version and return + // + var result = '' + version + '::' + hash.toString('hex'); + return Q.resolve(result); + }) + .catch(function(err) { + // + // Hash function failed, so return an error + // + return Q.reject(ERRORS.HASH_FAILED); + }); + } + + // + // Otherwise, this is an unknown algorithm version + // + return Q.reject(ERRORS.UNKNOWN_ALGO); +} + +/** + * Gets information from the hash value saved in the database. + * For hash format, see comments at the top of the file + * + * @param {String} dbHash - The hash string from the db + * + * @returns {Object} - 'version' and 'hash' split from the string + */ +function getHashInfo(dbHash) { + var fields = dbHash.split('::'); + if (fields.length === 1) { + // Special case for early encodings + return { + version: 1, + hash: fields[0] + }; + } else if (fields.length === 2) { + // Normal case + return { + version: Number(fields[0]), + hash: fields[1] + }; + } else { + // Something went wrong + return { + version: -1, + hash: null + }; + } +} diff --git a/node_server/utils/hashing.spec.js b/node_server/utils/hashing.spec.js new file mode 100644 index 0000000..5278ada --- /dev/null +++ b/node_server/utils/hashing.spec.js @@ -0,0 +1,125 @@ +var hashUtils = require('./hashing.js'); +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); +var expect = chai.expect; + +chai.use(chaiAsPromised); + +// +// Sample data +// +const samplePassword = '5678'; +const incorrectPassword = '5679'; + +// +// Current max password version +// +const latestVersion = 2; + +// +// Sample V1 hash: actually just a direct compare +// +const sampleHashV1 = '5678'; + +// +// Sample V2 values. Calculated indepedently using the Standford Javascript +// Crypto Library: http://bitwiseshiftleft.github.io/sjcl/ +// +const sampleHashV2 = '2::920290ac3bc5f38d78ca46a2e714da6f0e45a080d2a0259e09bc04cfa3d9b081'; +const sampleSaltV2 = '1ba8b6f708f075241f3d7cd9d63e0664b62e98f01ca83aafa453896fa49e8f1a'; + +describe('Hashing utilities', function() { + + describe('generateHash', function() { + it('should give hash+salt', function() { + var result = hashUtils.generateHash(2, samplePassword); + return expect(result).to.eventually.have.property('hash'); + }); + + it('should match on verify', function() { + var hash = hashUtils.generateHash(2, samplePassword); + var result = hash.then(function(newHash) { + return hashUtils.verifyHash(samplePassword, newHash.hash, newHash.salt, latestVersion); + }); + return expect(result).to.eventually.be.fulfilled; + }); + }); + + describe('valid v1 hash with matching password', function() { + var result = null; + + beforeEach('call verifyHash', function() { + result = hashUtils.verifyHash(samplePassword, sampleHashV1, '', latestVersion); + }); + + it('should match a v1 hash', function() { + return expect(result).to.eventually.be.fulfilled; + }); + + it('should generate a v2 hash', function() { + return expect(result).to.eventually.have.property('hash'); + }); + + it('should generate a new salt', function() { + return expect(result).to.eventually.have.property('salt'); + }); + + it('should generate a 64 character salt (32 bytes = 64 hex chars)', function() { + return result.then(function(newHash) { + expect(newHash.salt).to.have.length(64); + }); + }); + + it('should generate a 67 character hash ("2::" + 32 bytes/64 hex chars)', function() { + return result.then(function(newHash) { + expect(newHash.hash).to.have.length(67); + }); + }); + + it('should generate a hash in the right format', function() { + return result.then(function(newHash) { + expect(newHash.hash).to.match(/^2::[0-9a-z]{64}$/); + }); + }); + }); + + describe('valid v2 hash with matching password', function() { + var result = null; + + beforeEach('call verifyHash', function() { + result = hashUtils.verifyHash(samplePassword, sampleHashV2, sampleSaltV2, latestVersion); + }); + + it('should match a v2 hash', function() { + return expect(result).to.eventually.be.fulfilled; + }); + + it('should not generate a new hash/salt (already latest version)', function() { + return expect(result).to.eventually.equal(null); + }); + }); + + describe('wrong password', function() { + it('should not match a v1 hash', function() { + var result = hashUtils.verifyHash(incorrectPassword, sampleHashV1, '', latestVersion); + return expect(result).to.eventually.be.rejectedWith(hashUtils.ERRORS.NO_MATCH); + }); + + it('should not match a v2 hash', function() { + var result = hashUtils.verifyHash(incorrectPassword, sampleHashV2, '', latestVersion); + return expect(result).to.eventually.be.rejectedWith(hashUtils.ERRORS.NO_MATCH); + }); + }); + + describe('unknown latest version', function() { + it('v1 hash should fail to generate a new hash/salt', function() { + var result = hashUtils.verifyHash(samplePassword, sampleHashV1, '', latestVersion + 1); + return expect(result).to.eventually.be.rejectedWith(hashUtils.ERRORS.UNKNOWN_ALGO); + }); + + it('v2 hash should fail to generate a new hash/salt', function() { + var result = hashUtils.verifyHash(samplePassword, sampleHashV2, sampleSaltV2, latestVersion + 1); + return expect(result).to.eventually.be.rejectedWith(hashUtils.ERRORS.UNKNOWN_ALGO); + }); + }); +}); diff --git a/node_server/utils/init_morgan.js b/node_server/utils/init_morgan.js new file mode 100644 index 0000000..8de1182 --- /dev/null +++ b/node_server/utils/init_morgan.js @@ -0,0 +1,144 @@ +/** + * @fileOverview Helper utilities for initialising the morgan logging format + */ +'use strict'; +const morgan = require('morgan'); +const debug = require('debug')('logging:activity'); +const Writeable = require('stream').Writable; +const mainDBP = require('../ComServe/mainDB-promises'); + +let initialised = false; +const MAX_BUFFER = 1000; // Max entries to buffer if the db is down + +module.exports = { + init, + writeableStream +}; + +/** + * Initialises the morgan formats if it has not already been initialised + */ +function init() { + if (initialised) { + return; + } + + // + // Define a morgan token to get the userId from the session + // + morgan.token('user-id', (req) => { + if (req.session && req.session.data) { + return req.session.data.user; + } else { + return '-'; + } + }); + + // + // Define an Apache Combined Log equivalent format that uses our user id + // rather than default `basic-auth` user value. See: + // https://github.com/expressjs/morgan#user-content-combined + // for details of the base format. + // + morgan.format( + 'bridge-combined', + ':remote-addr - :user-id [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"' + ); + + initialised = true; +} + +/** + * Function to create a new record for storing in the database. + * + * @param {string} record - the record value from Morgan + * @returns {Object} - an object suitable for storing in MongoDB + */ +function entry(record) { + return { + timestamp: new Date(), + request: record + }; +} + +/** + * Returns a new Writeable stream which can be used to log Morgan entries to + * the database via Morgan's `stream` parameter. + * + * @returns {Writeable} - A Writeable stream for use with Morgan logging + */ +function writeableStream() { + let buffer = []; + let writePending = false; + const writeable = new Writeable({ + objectMode: true, + highWaterMark: 1, + write: function write(record, encoding, next) { + // Always log to stdout immediately + process.stdout.write(record + '\n'); + + if (writePending || !mainDBP.mainDB.dbOnline) { + // DB write in progress, or the DB is offline, so just buffer + if (buffer.length < MAX_BUFFER) { + buffer.push(entry(record)); + debug('Buffered log message:', buffer.length, writePending); + } else { + process.stderr.write('Activity log buffer exceeded. MESSAGES WILL BE LOST!\n'); + } + } else { + // Online so try to send entries to the db. + // There may be buffered entries, so swap them into pending array + // so more can buffer while we wait for the DB to confirm. + const pending = buffer.slice(); + buffer = []; + + // Add our new entry to the pending array + pending.push(entry(record)); + + // Try to upload them to mongo + debug('WRITE Started:', pending.length); + writePending = true; + mainDBP.addMany(mainDBP.mainDB.collectionActivityLog, pending, {}, false) + .then((result) => { + // The request ran, but may not have inserted everything. + // If it didn't we can't really know which ones were and + // were not inserted, so just notify the error. + if (result.result.ok) { + debug(' - WRITE OK:', result.result.n); + } else { + process.stderr.write('Some activity log entries may have failed to save to the db!\n'); + debug(' - Write partial failure:', result.result.n); + } + + writePending = false; + return result; + }) + .catch((error) => { + debug(' - WRITE ERROR received:', error); + + // The request didn't run for some reason; likely that the + // database went down. Add them back into the buffer, + // up to our max buffer size. + // Note: keep the original items as they are likely to be + // closer to the cause of the outage. + + // Add any new entries to the back of our pending list + const temp = pending.concat(buffer); + + // And copy up to MAX_BUFFER items back over to the buffer + buffer = temp.slice(0, MAX_BUFFER); + + writePending = false; + return null; // We handled the error, so no need to pass on + }); + } + + // + // Allow the server to continue without waiting for the result of the write to db + // + next(); + } + }); + return writeable; +} + diff --git a/node_server/utils/logging.js b/node_server/utils/logging.js new file mode 100644 index 0000000..8fae66c --- /dev/null +++ b/node_server/utils/logging.js @@ -0,0 +1,173 @@ +/** + * @fileOverview Utilities to simplify logging for the rest of the code. + */ +const winston = require('winston'); +const _ = require('lodash'); +const debug = require('debug')('logging:compliance'); + +/** + * Requiring `winston-mongodb` will expose + * `winston.transports.MongoDB` + */ +// eslint-disable-next-line import/no-unassigned-import +require('winston-mongodb'); + +module.exports = logging; + +const MONGO_LOG_COLLECTION = 'ComplianceLog'; + +/** + * Initialise log transports + */ +const transports = [ + new winston.transports.Console({ + name: 'console.info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.simple() + ), + colorize: true, + silent: false + }) +]; +let mongoTransport; + +/** + * Create a very basic logger + */ +const logger = winston.createLogger({ + level: 'info', + transports +}); + +/** + * Trivially handle the 'error' event to prevent unhandled exception errors. + */ +logger.on('error', (error) => { + debug('Logger error:', error); +}); + +/** + * Initialisation functions + */ +module.exports.init = { + initMongoTransport +}; + +/** + * For test, also export a way to control the logger + */ +module.exports._test = { + getLogger: () => logger, + getTransports: () => transports +}; + +/** + * @typedef {function} BridgeLogFunction + * @param {Object} req - Express request object (to access IP, request ID, etc.) + * @param {string} [req.ip] - The IP address of the caller + * @param {string} [req.bridgeUniqueId] - The unique id of this request + * @param {string} [req.sessionData.User] - UserID for the user the request is for + * @param {string} msg - The basic message to log + * @param {Object} [opt] - Optional object containing additional values to log. These will be + * automatically prefixed with '_' and added to the logged object. + */ + +/** + * @typedef {Object} BridgeLog + * @property {BridgeLogFunction} info - log at info level + * @property {BridgeLogFunction} error - log at error level + */ + +/** + * Factory function for initialising logging for the specified calling file. + * + * It returns an object containing log functions such as `log.info()`, `log.error()`. + * The format of this functions is defined as @see BridgeLogFunction. + * + * @example + * const log = require('/utils/logging.js')(__filename, 'utils:text:example'); + * log.info(req, 'Some info text I want to log'); + * log.error(req, 'Some error text I want to log', {customValue: 'Some custom value for this log'}); + * + * @param {string} file - The filename. Usually just __filename + * @param {string} logID - A colon seperated id for this log group (e.g as used by debug()) + * @returns {BridgeLog} - The logging object + */ +function logging(file, logID) { + return { + info: doLog.bind(undefined, file, logID, 'info'), + error: doLog.bind(undefined, file, logID, 'error') + }; +} + +/** + * Function to actually do the logging for any of the specific functions specified above. + * + * @param {string} file - the full filepath of the file that initialised this logger + * @param {string} logId - id of the log group + * @param {string} level - log level + * @param {Object} req - Express request object (to access IP, requestID etc.) + * @param {string} msg - The simple string to log + * @param {Object} opt - Additional options to log + */ +function doLog(file, logId, level, req, msg, opt) { + const toLog = { + level, + message: msg, + meta: { + logId, + ip: req.ip, + reqId: req.bridgeUniqueId, + userId: _.get(req, 'session.data.user'), + file + } + }; + const loggableOpt = _.mapKeys(opt, (value, key) => '_' + key); + + // + // Update the base object with the loggable options (prefix with _ per GELF); + // + _.assign(toLog.meta, loggableOpt); + + // + // Call the real logger and return the result + // + return logger.log(toLog); +} + +/** + * The MongoDB `Db` class from the NodeJS driver + * @typedef {Object} Db + */ + +/** + * Updates the logger to include a MongoDB transport that talks to the provided + * `db` instance. + * This also removes any prior MongoDB transport. + * + * @param {Db} db - the MongoDB `Db` instance to use for calling the DB. + */ +function initMongoTransport(db) { + // Remove any existing mongodb transport + if (mongoTransport) { + logger.remove(mongoTransport); + } + + // + // Create a new mongodb transport and register it with the logger + // + mongoTransport = new winston.transports.MongoDB({ + name: 'mongotransport', + decolorize: true, + storeHost: true, + db, + collection: MONGO_LOG_COLLECTION, + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf((info) => info.message) + ) + }); + + logger.add(mongoTransport); +} diff --git a/node_server/utils/paycodes.js b/node_server/utils/paycodes.js new file mode 100644 index 0000000..ef2c8e9 --- /dev/null +++ b/node_server/utils/paycodes.js @@ -0,0 +1,401 @@ +/** + * @fileOverview Utility functions for creating and verifying paycodes + */ +'use strict'; +/* eslint id-length: [1, {exceptions: ["x","y"], max: 50, min: 2}] */ + +const crypto = require('crypto'); +const debug = require('debug')('utils:paycodes'); +const BView = require('bit-buffer').BitView; +const BStream = require('bit-buffer').BitStream; +const {createError, paycodeString} = require('../ComServe/utils.js'); + +const PAYCODE_METHODS = ['Bridge', 'Credorax', 'WorldPay', 'RBS']; +const pCSAsciiToBin = [ + // 0 1 2 3 4 5 6 7 8 9 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + + // : ; < = > ? @ + -1, -1, -1, -1, -1, -1, -1, + + // A B C D E F G H J K L M N P R S T U V W X Y Z + 10, 11, 12, 13, 14, 15, 16, 17, -1, 18, 19, 20, 21, 22, -1, 23, -1, 24, 25, 26, 27, 28, 29, 30, 31, -1 +]; + +const DEFAULT_LENGTH = 5; +const DEFAULT_METHOD = 'Bridge'; + +module.exports = { + simplePayCode, + payCodeGeneration, + payCodeValidate, + + PAYCODE_METHODS +}; + +/** + * A function to simplify generating paycode using reasonable defaults + * + * @returns {String|number} - Paycode string or -1 on error + */ +function simplePayCode() { + return payCodeGeneration(paycodeString, DEFAULT_LENGTH, DEFAULT_METHOD); +} + +/** + * Generates a combined Bank routing and random code. This function is strong + * enough for cryptographic use unless system entropy is below a certain level. + * + * @param {String} list - The string to use e.g. 'utils.numeric' for decimal. From options in utils.js + * @param {!int} length - The length of the resulting output string. + * @param {!string} method - The string to use to describe the payment method. All shown at the top of this file. + * @return {string} payCodeStr - A generated paycode string based on the PayCode Wiki definition, + * or -1 if an illegal method, or length requested + */ +// eslint-disable-next-line complexity +function payCodeGeneration(list, length, method) { + /** + * Local variables. + */ + let x; + let y; + + /** + * Now generate the dimensions and bitstream array positions. + */ + const size = payCodeDimensions(length); // used for all the bit string indexes + + /** + * Check for paycode dimensions failure. + */ + if (size === -1) { + debug('Paycode Generation: Error-PayCodeDimensions failed (length:', length, ')'); + return -1; + } + + /** + * Check that the provided list is long enough. + */ + if (list.length < Math.pow(2, size.bitChar)) { + debug('Paycode Generation: Error-PayCode String List to short (length:', list.length, ')'); + return -1; + } + + /** + * Generate the Bit array + */ + const array = new ArrayBuffer(size.bitLength); + const bv = new BView(array); + const bs = new BStream(bv); + + /** + * Initialise the bitString, should be zero, and is overwritten, but be paranoid. + */ + for (x = 0; x < size.bitLength; x++) { + bs.writeBits(0, 1); + } + + /** + * BankID is the index into the Method array. + */ + const bankID = PAYCODE_METHODS.indexOf(method); + if (bankID === -1) { + debug('Paycode Generation: Error Bank method not found:', method, bankID); + return -1; + } + + /** + * Check BankID size. + */ + if (bankID > (Math.pow(2, size.bankBits))) { + debug('Paycode Generation: Error BankID length wrong'); + return -1; + } + + /** + * Add in the bank ID. + */ + bv.setBits(size.bankPosition, bankID, size.bankBits); + + /** + * Generate random stream of bytes for the unique code of the paycode + * Note: No psuedo random stream back up if call fails ... Possible TBD ? + * If the call fails and throw's an error + */ + const bytes = crypto.randomBytes(Math.ceil((size.bitLength / 8))); // used for a random number string + + /** + * todo Blocking behaviour may have changed. + * The crypto.randomBytes() method will block until there is sufficient entropy. + * This should normally never take longer than a few milliseconds. + * The only time when generating the random bytes may conceivably block for a + * longer period of time is right after boot, when the whole system is still low on entropy. + */ + + /** + * Shift random values into the paycode bit stream. + */ + for (x = 0; x < (Math.ceil(size.uniqueCode / 8)); x++) { + /** + * Work through each byte and then add in the remainder, until random string of bits in the unique code position and length. + */ + if (x < Math.floor(size.uniqueCode / 8)) { + bv.setUint8(((x * 8) + size.uniqueCodePosition), bytes[x]); + } else { + /** + * Does not need a mask as only uses the required bits. + */ + bv.setBits(((x * 8) + size.uniqueCodePosition), bytes[x], (size.uniqueCode % 8)); + } + } + + /** + * XOR the bankid, with the last bankid length of bits before the checksum. + */ + const bankVal = bv.getBits(size.bankPosition, size.bankBits, false); + const lastCodeVal = bv.getBits(size.uniqueCodePosition, size.bankBits, false); + + /** + * Be aware that there may be a 32 bit limit on the Xor that is not checked for here + * as the paycode construction precludes this, as the bankBits are always less than 32 + * however should we go for much larger paycodes with corresponding increases in bankbits + * there could be a problem here. + */ + /* jshint -W016 */ + const xorResult = bankVal ^ lastCodeVal; + /* jshint +W016 */ + + /** + * Then put it back in. + */ + bv.setBits(size.bankPosition, xorResult, size.bankBits); + + /** + * Finally the generate the Checksum. + */ + let checkSum = 0; + for (x = size.uniqueCodePosition; x < size.bitLength; x += size.checkSumLength) { + if ((size.bitLength - x) > size.checkSumLength) { + /* jshint -W016 */ + checkSum ^= bv.getBits(x, size.checkSumLength, false); + /* jshint +W016 */ + } else { + /* jshint -W016 */ + checkSum ^= bv.getBits(x, (size.bitLength - x), false); + /* jshint +W016 */ + } + } + + /** + * Put the checksum into the code. + */ + bv.setBits(size.checkSumPosition, checkSum, size.checkSumLength); + + /** + * Generate the paycode string from the bitStream. + */ + let payCodeStr = ''; + for (x = 0, y = size.bitLength - size.bitChar; x < length; x++, y -= size.bitChar) { + payCodeStr += list[bv.getBits(y, size.bitChar, false)]; + } + + /* + * Return the string. + */ + return payCodeStr; +} + +/** + * Check the PayCode for validity with checks for length, checksum, bankid/method. + * + * @type {Function} payCodeValidation + * @param {!string} payCodeStr - The payCode to validate. + * @returns {Object} error - A detailed error response, include a success message (if achieved). + */ +// eslint-disable-next-line complexity +function payCodeValidate(payCodeStr) { + /** + * Local variables. + */ + let error; + let x; + let y; + + /** + * Error method that requires codes (note the user may want to know the paycode is the wrong length if typed). + */ + error = createError(10000, 'Success'); + + /** + * check the string length. + */ + if ((payCodeStr.length < 5) || (payCodeStr.length > 10) || (payCodeStr.length === 9)) { + error = createError(330, 'Error-Incorrect PayCode Length'); + return error; + } + + /** + * Now we have confirmed a valid paycode length we need to setup the position array. + */ + const size = payCodeDimensions(payCodeStr.length); + + /** + * Check for paycodedimensions failure + */ + if (size === -1) { + error = createError(330, 'Error-PayCodeDimensions failed'); + return error; + } + + /** + * Create the binary stream and view. + */ + const array = new ArrayBuffer(size.bitLength); + const bv = new BView(array); + + /** + * Generate the bitStream from the paycode. + * Trying to save on searches etc, do a lookup. + */ + const str = '0'; + const lim0 = str.charCodeAt(); + for (x = 0, y = size.bitLength - size.bitChar; x < payCodeStr.length; x++, y -= size.bitChar) { + const code = payCodeStr[x].charCodeAt(); + const index = code - lim0; + if ((index < pCSAsciiToBin.length) && (pCSAsciiToBin[index] !== -1)) { + bv.setBits(y, pCSAsciiToBin[index], size.bitChar); + } else { + /** + * Error Condition + */ + error = createError(330, 'Error-Incorrect PayCode formatting'); + return error; + } + } + + /** + * Check the checksum. + */ + let checkSum = 0; + + /** + * Re-generate the Checksum. + */ + for (x = size.uniqueCodePosition; x < (size.bitLength); x += size.checkSumLength) { + if ((size.bitLength - x) > size.checkSumLength) { + /* jshint -W016 */ + checkSum ^= bv.getBits(x, size.checkSumLength, false); + /* jshint +W016 */ + } else { + /* jshint -W016 */ + checkSum ^= bv.getBits(x, (size.bitLength - x), false); + /* jshint +W016 */ + } + } + + /** + * Get the checksum from the paycode code. + */ + const payCodeCheckSum = bv.getBits(size.checkSumPosition, size.checkSumLength, false); + if (checkSum !== payCodeCheckSum) { + error = createError(330, 'Error-Incorrect PayCode checksum'); + return error; + } + + /** + * Now extract the bank ID. + * XOR the bankid, with the last bankid length of bits before the checksum. + */ + const bankVal = bv.getBits(size.bankPosition, size.bankBits, false); + const lastCodeVal = bv.getBits(size.uniqueCodePosition, size.bankBits, false); + /* jshint -W016 */ + const bankID = bankVal ^ lastCodeVal; + /* jshint +W016 */ + if (bankID > PAYCODE_METHODS.length) { + error = createError(330, 'Error-BankID not valid method'); + return error; + } + + /* + * Return the Error. + */ + return error; +} + +/* + * Return the paycode bit dimensions based on the number of characters. + * + * @type {function} payCodeDimensions + * @param {!int} length - The payCode length. + * @return {object} errorReturn - returns -1 or the size array containing the bit details of the code. + */ +function payCodeDimensions(length) { + /** + * Local variables. + */ + const size = {}; + + /* + * Paycode formats as follows + * Chars 5 6 7 8 10 + * Length(Bits) 25 30 35 40 50 + * Bank bits 4 6 9 13 21 + * Unique(RND) 19 21 22 23 24 + * ChkSum 2 3 4 4 5 + */ + + /** + * Size of paycode Character in bits. + */ + size.bitChar = 5; + + /** + * Size of paycode. + */ + size.bitLength = length * size.bitChar; + + /** + * Sizing of paycode internals, based on Phabricator Wiki. + */ + if (length === 5) { + // IS/DK/SCO + size.bankBits = 4; + size.uniqueCode = 19; + size.checkSumLength = 2; + } else if (length === 6) { + // UK/ES/ER + size.bankBits = 6; + size.uniqueCode = 21; + size.checkSumLength = 3; + } else if (length === 7) { + // DE/JPN/RU + size.bankBits = 9; + size.uniqueCode = 22; + size.checkSumLength = 4; + } else if (length === 8) { + // USA/CN/IN + size.bankBits = 13; + size.uniqueCode = 23; + size.checkSumLength = 4; + } else if (length === 10) { + // International + size.bankBits = 21; + size.uniqueCode = 24; + size.checkSumLength = 5; + } else { + /* + * Return error, not handled code length. + */ + debug('PayCodeDimensions: Error PayCode length unsupported'); + return -1; + } + + /* + * Some positional pointers for bit handling, note LSB/MSB for position, due to bitstream/view model + */ + size.bankPosition = size.checkSumLength + size.uniqueCode; + size.uniqueCodePosition = size.checkSumLength; + size.checkSumPosition = 0; + + return size; +} diff --git a/node_server/utils/postcodes.js b/node_server/utils/postcodes.js new file mode 100644 index 0000000..33ced2f --- /dev/null +++ b/node_server/utils/postcodes.js @@ -0,0 +1,101 @@ +/** + * Utils for looking up postcodes from addresses + */ +'use strict'; + +const debug = require('debug')('utils:postcodes'); +const Q = require('q'); + +const config = require(global.configFile); +const idealPostcodes = require('ideal-postcodes')(config.idealPostcodesKey); +const UkClearAddressing = require('uk-clear-addressing'); + +const ERRORS = { + UNSPECIFIED: 'Unspecified error' +}; + +module.exports = { + ERRORS, + postcodeLookup +}; + +/** + * Runs a postcode lookup and returns a list of addresses that could match that + * postcode. + * + * @param {string} postcode - the postcode to lookup addresses for + * @returns {Promise} - promise for array of addressses + */ +function postcodeLookup(postcode) { + debug('Postcode Lookup: ', postcode); + + return Q.ninvoke( + idealPostcodes, + 'lookupPostcode', + postcode + ).then((addresses) => { + const formatted = []; + for (let i = 0; i < addresses.length; ++i) { + formatted.push(pafToBridgeAddress(addresses[i])); + } + debug('Formatted:', formatted); + + return formatted; + }).catch(() => { + // + // The API doesn't really make it possible to differentatiate errors at + // this time. + // + const newError = ERRORS.UNSPECIFIED; + return Q.reject(newError); + }); +} + +/** + * Converst a PAF (Royal Mail Postcode Address File) format address to a + * Bridge style one. + * + * @param {Object} pafAddr - the address is PAF format + * @returns {Object} - the address in Bridge format + */ +function pafToBridgeAddress(pafAddr) { + // + // Use the uk-clear-addressing module to do most of the work for us + // + const clearAddr = new UkClearAddressing(pafAddr).formattedAddress(); + + // Disable the variable case error because uk-clear-addressing uses snake case + // jshint -W106 + const bridgeAddress = { + Town: clearAddr.post_town, + PostCode: clearAddr.postcode, + Country: 'United Kingdom' + }; + if (clearAddr.line_3) { + // + // The address has 3 lines, so put the first one in BuildingNameFlat + // + bridgeAddress.BuildingNameFlat = clearAddr.line_1; + bridgeAddress.Address1 = clearAddr.line_2; + bridgeAddress.Address2 = clearAddr.line_3; + } else if (clearAddr.line_1 === pafAddr.sub_building_name) { + // + // We differentiate between "BuildingNameFlat" and Address1, whereas + // UkClearAddressing doesn't. So if line_1 is just the sub building name, + // then put that in flat, and move line 2 up. + // + bridgeAddress.BuildingNameFlat = clearAddr.line_1; + bridgeAddress.Address1 = clearAddr.line_2; + } else { + // + // This address has at most 2 lines, so just use the main fields. + // + bridgeAddress.Address1 = clearAddr.line_1; + bridgeAddress.Address2 = clearAddr.line_2; + } + + // + // Convert the fields accross to our names for them. + // + return bridgeAddress; +} diff --git a/node_server/utils/promises.js b/node_server/utils/promises.js new file mode 100644 index 0000000..392d56d --- /dev/null +++ b/node_server/utils/promises.js @@ -0,0 +1,142 @@ +/** + * Support utilities for using Promises, particularls Kris Kowal's Q: + * @see {@link https://github.com/kriskowal/q} + * + * In particular, these utilities help with sending error responses through + * a promise chain. The first time an error is received, the error handler + * should call `return returnChainedError()`. Later handlers should then check + * if there is a previous error (`hasChainedError()`), and if so just send it + * on (`return resendChainedError()`). The final error handler can then return + * the chained error. + */ +'use strict'; + +var Q = require('q'); +var httpStatus = require('http-status-codes'); +var _ = require('lodash'); +var debug = require('debug')('webconsole-api:utils:promises'); + +const ERR_KEY = 'cmcrdErrResponse'; + +module.exports = { + ERR_KEY: ERR_KEY, + ErrorResponse: ErrorResponse, + + sendErrorResponse: sendErrorResponse, + + returnChainedError: returnChainedError, + resendChainedError: resendChainedError, + hasChainedError: hasChainedError, + getChainedError: getChainedError +}; + +/** + * Constructs a new ErrorResponse that is used for passing errors through a + * promise chain. + * + * @class + * @param {integer} httpcode - the http status code to respond with + * @param {integer} code - the Bridge error code + * @param {String} info - the further information string + */ +function ErrorResponse(httpcode, code, info) { + // + // Assign the values + // + this.httpcode = httpcode; + this.errorInfo = { + code: code, + info: info + }; +} + +/** + * Returns an error in an appropriate way for chaining through a promise + * chain. + * + * @param {Object} err - the existing error for manipulating + * @param {integer} httpcode - the http status code to respond with + * @param {integer} code - the Bridge error code + * @param {String} info - the further information string + * + * return {Promise} - rejected promise with the error info added + */ +function returnChainedError(err, httpcode, code, info) { + var response = new ErrorResponse(httpcode, code, info); + + // + // If err isn't already an object, turn it into one + // + if (!_.isObject(err)) { + var original = err; + err = { + originalErr: err + }; + } + err[ERR_KEY] = response; + + return resendChainedError(err); +} + +/** + * Sends on an error again as a rejected promise + * + * @param {Object} err - the existing error for manipulating + * + * return {Promise} - rejected promise with the error info added + */ +function resendChainedError(err) { + var deferred = Q.defer(); + deferred.reject(err); + return deferred.promise; +} + +/** + * Checks if there is already a chained error in this error object + * + * @param {Object} err - the err reason object + */ +function hasChainedError(err) { + return err.hasOwnProperty(ERR_KEY); +} + +/** + * Gets the chained error information + * + * @param {Object} err - the error object + * + * @return {?ErrorResponse} - the error response info (if any) + */ +function getChainedError(err) { + if (!hasChainedError(err)) { + return null; + } else { + return err[ERR_KEY]; + } +} + +/** + * Returns an error back to the client. This is either the error that has + * been passed through the promise chain, or a default unknown error is + * returned. + * + * @param {Object} res - Express response object + * @param {Object} err - the error object + */ +function sendErrorResponse(res, err) { + var response = getChainedError(err); + if (!response) { + response = new ErrorResponse( + httpStatus.INTERNAL_SERVER_ERROR, + -1, + 'Unknown error' + ); + } + + debug(' - send error: [%s] - {%d, %s}', + response.httpcode, + response.errorInfo.code, + response.errorInfo.info + ); + res.status(response.httpcode).json(response.errorInfo); +} diff --git a/node_server/utils/references.js b/node_server/utils/references.js new file mode 100644 index 0000000..bb0d256 --- /dev/null +++ b/node_server/utils/references.js @@ -0,0 +1,222 @@ +/** + * Checks the validity of references (e.g. to addresses) + */ +'use strict'; + +var mongodb = require('mongodb'); +var Q = require('q'); +var mainDB = require(global.pathPrefix + 'mainDB.js'); +var utils = require(global.pathPrefix + 'utils.js'); + +const ERRORS = { + INVALID_ADDRESS: 'BRIDGE: INVALID ADDRESS', + INVALID_ACCOUNT: 'BRIDGE: INVALID ACCOUNT', + INVALID_CLIENT: 'BRIDGE: INVALID_CLIENT', + INVALID_DEVICE: 'BRIDGE: INVALID_DEVICE' +}; + +module.exports = { + isValidAddressRef: isValidAddressRef, + getAccount: getAccount, + getEmailAddress: getEmailAddress, + getClient: getClient, + getClientByEmail: getClientByEmail, + getDevice: getDevice, + + ERRORS: ERRORS +}; + +/** + * Function to check that the given address reference is valid for the client + * + * @param {string} clientID - The client's unique id + * @param {string} addressRef - The address id to check + * @param {string} caller - The calling function's name (for debug) + * + * @returns {promise} - Promise that resolves to the found address + * or rejects with an error code + */ +function isValidAddressRef(clientID, addressRef, caller) { + var addrQuery = { + _id: mongodb.ObjectID(addressRef), // The id given + ClientID: clientID // Must be *my* address + }; + var options = { + fields: {}, // Don't want any fields, just checking existence + comment: caller + }; + + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionAddresses, + addrQuery, + options, + false + ).then(function(item) { + if (item === null) { + // + // Didn't find a matching address (doesn't exist, or + // doesn't belong to client). + // + return Q.reject({name: ERRORS.INVALID_ADDRESS}); + } else { + // + // Item was found so it exists and belongs to this client + // + return Q.resolve(item); + } + }); +} + +/** + * Function that gets an account from an ID, if it belongs to the specified + * client. It finds only active accounts by default, but can optionally find + * deleted accounts. + * + * @param {string} accountID - The id of the account to find + * @param {string} clientID - The client's unique ID + * @param {?bool} includeDeleted - True to include deleted accounts + * + * @returns {Promise} - Promise that resolves to the account or rejects with error + */ +function getAccount(accountID, clientID, includeDeleted) { + var query = { + _id: mongodb.ObjectID(accountID), + ClientID: clientID + }; + var options = { + comment: 'references:getAccount' + }; + + if (!includeDeleted) { + query.AccountStatus = { + $bitsAllClear: utils.AccountDeleted + }; + } + + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionAccount, + query, + options, + false // Don't suppress errors + ).then(function(account) { + if (!account) { + return Q.reject({name: ERRORS.INVALID_ACCOUNT}); + } else { + return account; + } + }); +} + +/** + * Returns a promise for the email address for a client based on their ID + * + * @param {String} clientID - The unique id for the client + * + * @return {Promise} - Promise for the email address if found + */ +function getEmailAddress(clientID) { + return getClient(clientID).then((client) => client.ClientName); +} + +/** + * Returns a promise for the client object based on their ID + * + * @param {String} clientID - The unique id for the client + * + * @return {Promise} - Promise for the client object if found + */ +function getClient(clientID) { + var query = { + ClientID: clientID + }; + var options = { + comment: 'references:getClient' + }; + + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + query, + options, + false // Don't suppress errors + ).then(function(client) { + if (!client) { + return Q.reject({name: ERRORS.INVALID_CLIENT}); + } else { + return client; + } + }); +} + +/** + * Returns a promise for the client object based on their ID + * + * @param {String} email - The email for the client + * @returns {Promise} - Promise for the client object if found + */ +function getClientByEmail(email) { + var query = { + ClientName: email + }; + var options = { + comment: 'references:getClientByEmail' + }; + + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + query, + options, + false // Don't suppress errors + ).then(function(client) { + if (!client) { + return Q.reject({name: ERRORS.INVALID_CLIENT}); + } else { + return client; + } + }); +} + +/** + * Returns a promise for the active device belong to a client + * +* @param {string} deviceNumber - The number of the device to find + * @param {string} clientID - The client's unique ID + * @param {?bool} includeInactive - True to include devices that are barred/disabled/etc + * + * @returns {Promise} - Promise that resolves to the account or rejects with error + */ +function getDevice(deviceNumber, clientID, includeInactive) { + const query = { + ClientID: clientID, + DeviceNumber: deviceNumber + }; + const options = {}; + + if (!includeInactive) { + /* Expected use of bitwise & */ + /* jshint -W016 */ + query.DeviceStatus = { + $bitsAllSet: utils.DeviceFullyRegistered, + $bitsAllClear: utils.DeviceSuspendedMask & utils.DeviceBarredMask + }; + /* jshint +W016 */ + } + + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionDevice, + query, + options, + false // Don't suppress errors + ).then(function(device) { + if (!device) { + return Q.reject({name: ERRORS.INVALID_DEVICE}); + } else { + return device; + } + }); +} + diff --git a/node_server/utils/responses.js b/node_server/utils/responses.js new file mode 100644 index 0000000..15fd8e2 --- /dev/null +++ b/node_server/utils/responses.js @@ -0,0 +1,124 @@ +/** + * Support utilities for implementing error responses + */ +'use strict'; + +var _ = require('lodash'); +var debug = require('debug')('webconsole-api:responses'); +var auth = require(global.pathPrefix + 'auth.js'); + +/** + * Constant value for the default response if we don't fund something more specific + */ +const DEFAULT_RESPONSE = { + httpCode: 500, // INTERNAL SERVER ERROR + bodyCode: -1, + bodyDesc: 'Unspecified error' +}; + +/** + * A class to handle making error responses. + * + * @class + */ +module.exports.ErrorResponses = class ErrorResponses { + /** + * Creates an instance of ErrorResponses, based on a table of errors. + * This table should be an array of arrays. Each item in the overall array + * should have the following params in order: + * [0] - The ID of the error in the error being handled + * [1] - The http response code for this error + * [2] - The code in the JSON response + * [3] - The description in the JSON response + * [4] - [OPTIONAL] true = the response is in `error.name` rather than the top level + * + * @param {any[]} responses - the responses table + * + * @memberOf ErrorResponses + */ + constructor(responses) { + this.baseResponses = {}; + this.nameResponses = {}; + + const ERROR_ID = 0; + const HTTP_CODE = 1; + const BODY_CODE = 2; + const BODY_DESC = 3; + const IS_NAME = 4; + + for (let i = 0; i < responses.length; ++i) { + let response = responses[i]; + let table = this.baseResponses; + + if (response[IS_NAME]) { + table = this.nameResponses; + } + + table[response[ERROR_ID]] = { + httpCode: response[HTTP_CODE], + bodyCode: response[BODY_CODE], + bodyDesc: response[BODY_DESC] + }; + } + } + + /** + * Responds to the WEB caller with an appropriate error message based on the + * error info. + * + * @param {Object} res - the express response handler + * @param {any} error - the error info + */ + respond(res, error) { + debug('Request Error', error); + let response = this.findResponse(error); + + res.status(response.httpCode).json({ + code: response.bodyCode, + info: response.bodyDesc + }); + } + + /** + * Responds to the APP caller with an appropriate error message based on the + * error info. + * + * @param {Object} res - the express response handler + * @param {any} error - the error info + * @param {any} device - the device the app is running on + * @param {any} hmacData - data for calculating HMAC + * @param {any} functionInfo - info about the current function + * @param {any} level - the level of notification to log + */ + respondAuth(res, error, device, hmacData, functionInfo, level) { + let response = this.findResponse(error); + auth.respond(res, response.httpCode, device, hmacData, functionInfo, + { + code: '' + response.bodyCode, + info: response.bodyDesc + }, + level + ); + } + + /** + * Find the response from our table of responses + * + * @param {any} error - the error to lookup + * @returns {Object} - the response to use + */ + findResponse(error) { + let response = DEFAULT_RESPONSE; + if (error.hasOwnProperty('name')) { + if (this.nameResponses.hasOwnProperty(error.name)) { + response = this.nameResponses[error.name]; + } + } else if (_.isString(error)) { + if (this.baseResponses.hasOwnProperty(error)) { + response = this.baseResponses[error]; + } + } + + return response; + } +}; diff --git a/node_server/utils/specs/anon.spec.js b/node_server/utils/specs/anon.spec.js new file mode 100644 index 0000000..5da55ea --- /dev/null +++ b/node_server/utils/specs/anon.spec.js @@ -0,0 +1,57 @@ +/** + * Unit testing file for anon + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const chai = require('chai'); +const sinonChai = require('sinon-chai'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const anon = rewire('../anon.js'); + +const expect = chai.expect; +chai.use(sinonChai); + +describe('anon functions', () => { + describe('call anonymiseWorldpayService', () => { + it('anonymises a standard serviceKey', () => { + const result = anon.anonymiseWorldpayServiceKey('T_S_713d2a60-a20b-4047-bc3a-3e863a11e414'); + + return expect(result).to.deep.equal('T_S_********-****-****-****-********e414'); + }); + it('throws when service key is not set', () => { + return expect(() => anon.anonymiseWorldpayServiceKey(undefined)).to.throw('service key not set'); + }); + it('throws when service key is not a valid worldpay service key (invalid patttern)', () => { + return expect(() => anon.anonymiseWorldpayServiceKey('T_A_713d2a60-a20b-4047-bc3a-3e863a11e414')).to.throw('service key not consistent with a Worldpay service key'); + }); + it('throws when service key is not a valid worldpay service key (to short)', () => { + return expect(() => anon.anonymiseWorldpayServiceKey('T_S_713d2a60-a20b-4047-bc3a')).to.throw('service key not consistent with a Worldpay service key'); + }); + }); + describe('call anonymiseCardPAN', () => { + it('anonymises a standard cardPAN', () => { + const result = anon.anonymiseCardPAN('0000111122223333'); + + return expect(result).to.deep.equal('0*** **** **** *333'); + }); + it('anonymises a standard cardPAN with spaces', () => { + const result = anon.anonymiseCardPAN('0000 1111 2222 3333'); + + return expect(result).to.deep.equal('0*** **** **** *333'); + }); + it('anonymises a short cardPAN', () => { + const result = anon.anonymiseCardPAN('0000'); + + return expect(result).to.deep.equal('0000'); + }); + it('throws when card PAN is not set', () => { + return expect(() => anon.anonymiseCardPAN(undefined)).to.throw('cardPAN not set'); + }); + }); +}); diff --git a/node_server/utils/specs/encryption.spec.js b/node_server/utils/specs/encryption.spec.js new file mode 100644 index 0000000..ed0aa5a --- /dev/null +++ b/node_server/utils/specs/encryption.spec.js @@ -0,0 +1,408 @@ +/* eslint-disable no-empty */ +/** + * Unit testing file for encryption + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] */ +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const _ = require('lodash'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const rewire = require('rewire'); + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const encryption = rewire('../encryption.js'); +const utilsStub = encryption.__get__('utils'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); + +const ENCRYPTION_KEY = 'go3rn2ofno2'; +const USER_ID = 'o5oij5oioj23oij'; + +const FAKE_ENCRYPTED_DETAILS = '4j3nrkj23b4rk'; +const FAKE_ERROR = {error: 'This is an error'}; + +const CARD_PAN = '0000000000000000'; +const CARD_EXPIRY = '01-20'; +const CARD_VALID_FROM = '01-15'; +const CARD_ISSUE_NO = '00'; +const UNENCRYPTED_DETAILS = { + FirstName: 'Joe', + LastName: 'Bloggs' +}; +const MIN_ACCOUNT = { + CreditDebitCardInfo: _.defaults( + { + CardPanToBeEncrypted: CARD_PAN, + CardExpiryToBeEncrypted: CARD_EXPIRY, + CardPANEncrypted: '', + CardExpiryEncrypted: '', + CardValidFromEncrypted: '', + IssueNumberEncrypted: '' + }, + UNENCRYPTED_DETAILS + ) +}; +const MAX_ACCOUNT = _.defaultsDeep( + { + CreditDebitCardInfo: { + CardValidFromToBeEncrypted: CARD_VALID_FROM, + IssueNumberToBeEncrypted: CARD_ISSUE_NO + } + }, + MIN_ACCOUNT +); + +const MIN_DATA = { + Account: MIN_ACCOUNT +}; +const MAX_DATA = { + Account: MAX_ACCOUNT +}; +const MIN_ENCRYPTED_ACCOUNT = { + CreditDebitCardInfo: _.defaultsDeep( + { + CardExpiryEncrypted: FAKE_ENCRYPTED_DETAILS, + CardPANEncrypted: FAKE_ENCRYPTED_DETAILS + }, + UNENCRYPTED_DETAILS + ) +}; + +const MAX_ENCRYPTED_ACCOUNT = _.defaultsDeep( + { + CreditDebitCardInfo: { + CardValidFromEncrypted: FAKE_ENCRYPTED_DETAILS, + IssueNumberEncrypted: FAKE_ENCRYPTED_DETAILS + } + }, + MIN_ENCRYPTED_ACCOUNT +); +const MIN_DECRYPTED_RETURN_OBJECT = { + expiryMonth: '01', + expiryYear: '2020', + cardNumber: CARD_PAN +}; +const MAX_DECRYPTED_RETURN_OBJECT = _.defaultsDeep( + { + IssueNumber: 0, + startMonth: '01', + startYear: '2015' + }, + MIN_DECRYPTED_RETURN_OBJECT +); +const MIN_DECRYPTED_FULL_ACCOUNT = { + CreditDebitCardInfo: _.defaultsDeep( + {}, + UNENCRYPTED_DETAILS, + MIN_DECRYPTED_RETURN_OBJECT + ) +}; +const MAX_DECRYPTED_FULL_ACCOUNT = { + CreditDebitCardInfo: _.defaultsDeep( + {}, + UNENCRYPTED_DETAILS, + MAX_DECRYPTED_RETURN_OBJECT + ) +}; +const MIN_ENCRYPTED_RETURN_OBJECT = { + CardPANEncrypted: FAKE_ENCRYPTED_DETAILS, + CardExpiryEncrypted: FAKE_ENCRYPTED_DETAILS +}; +const MAX_ENCRYPTED_RETURN_OBJECT = _.defaultsDeep( + { + CardValidFromEncrypted: FAKE_ENCRYPTED_DETAILS, + IssueNumberEncrypted: FAKE_ENCRYPTED_DETAILS + }, + MIN_ENCRYPTED_RETURN_OBJECT +); +const MIN_ENCRYPTED_FULL_ACCOUNT = { + Account: { + CreditDebitCardInfo: _.defaultsDeep( + { + CardValidFromEncrypted: '', + IssueNumberEncrypted: '' + }, + MIN_ENCRYPTED_RETURN_OBJECT, + UNENCRYPTED_DETAILS + ) + } +}; +const MAX_ENCRYPTED_FULL_ACCOUNT = { + Account: { + CreditDebitCardInfo: _.defaultsDeep( + {}, + MAX_ENCRYPTED_RETURN_OBJECT, + UNENCRYPTED_DETAILS + ) + } +}; +const INVALID_ACCOUNT = {}; + +describe('encryption function', () => { + /** + * Stub the functions that will be used for the "happy path" + * The responses are specifically overriden below for testing the error cases + */ + beforeEach(() => { + sandbox.spy(encryption, 'encryptCard'); + sandbox.spy(encryption, 'decryptCard'); + sandbox.spy(encryption, 'encryptCardMaintainingAccount'); + sandbox.spy(encryption, 'decryptCardMaintainingAccount'); + + sandbox.stub(utilsStub, 'decryptDataV3') + .onCall(0).returns(CARD_EXPIRY) + .onCall(1).returns(CARD_PAN) + .onCall(2).returns(CARD_ISSUE_NO) + .onCall(3).returns(CARD_VALID_FROM); + sandbox.stub(utilsStub, 'encryptDataV3') + .onCall(0).returns(FAKE_ENCRYPTED_DETAILS) + .onCall(1).returns(FAKE_ENCRYPTED_DETAILS) + .onCall(2).returns(FAKE_ENCRYPTED_DETAILS) + .onCall(3).returns(FAKE_ENCRYPTED_DETAILS); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('calls encryptCard', () => { + describe('successfully', () => { + describe('with required card fields set', () => { + beforeEach(() => { + encryption.encryptCard(MIN_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + }); + it('encrypting cardPAN and expiry date', () => { + return expect(utilsStub.encryptDataV3).to.have.been + .calledTwice + .calledWith(CARD_PAN, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_EXPIRY, ENCRYPTION_KEY, USER_ID); + }); + it('returning encrypted details ', () => { + return expect(encryption.encryptCard).to.have.returned(MIN_ENCRYPTED_RETURN_OBJECT); + }); + }); + describe('with all fields set', () => { + beforeEach(() => { + encryption.encryptCard(MAX_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + }); + it('encrypting valid from, issue no., cardPAN and expiry date', () => { + return expect(utilsStub.encryptDataV3).to.have.been + .callCount(4) + .calledWith(CARD_PAN, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_EXPIRY, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_VALID_FROM, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_ISSUE_NO, ENCRYPTION_KEY, USER_ID); + }); + it('returning encrypted details ', () => { + return expect(encryption.encryptCard).to.have.returned(MAX_ENCRYPTED_RETURN_OBJECT); + }); + }); + }); + describe('with a failure', () => { + describe('to encrypt the data', () => { + beforeEach(() => { + utilsStub.encryptDataV3 + .onCall(0).returns(FAKE_ERROR); + + try { + encryption.encryptCard(MIN_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + } catch (error) {} + }); + it('fails to encrypt cardPAN', () => { + return expect(utilsStub.encryptDataV3).to.have.been + .calledOnce + .calledWith(CARD_PAN, ENCRYPTION_KEY, USER_ID); + }); + it('throwing an error', () => { + return expect(encryption.encryptCard).to.have.thrown(); + }); + }); + describe('to send invalid data to encrypt', () => { + beforeEach(() => { + try { + encryption.encryptCard(INVALID_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + } catch (error) {} + }); + it('does not try to encrypt anything', () => { + return expect(utilsStub.encryptDataV3).to.not.have.been.called; + }); + it('throwing an error', () => { + return expect(encryption.encryptCard).to.have.thrown(); + }); + }); + }); + }); + describe('calls decryptCard', () => { + describe('successfully', () => { + describe('with required card fields set', () => { + beforeEach(() => { + encryption.decryptCard(MIN_ENCRYPTED_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + }); + it('decrypting cardPAN and expiry date', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .calledTwice + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID); + }); + it('returning decrypted details ', () => { + return expect(encryption.decryptCard).to.have.returned(MIN_DECRYPTED_RETURN_OBJECT); + }); + }); + describe('with all fields set', () => { + beforeEach(() => { + encryption.decryptCard(MAX_ENCRYPTED_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + }); + it('decrypting valid from, issue no., cardPAN and expiry date', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .callCount(4) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID); + }); + it('returning decrypted details ', () => { + return expect(encryption.decryptCard).to.have.returned(MAX_DECRYPTED_RETURN_OBJECT); + }); + }); + }); + describe('with a failure', () => { + describe('to decrypt the data', () => { + beforeEach(() => { + utilsStub.decryptDataV3 + .onCall(0).returns(FAKE_ERROR); + try { + encryption.decryptCard(MIN_ENCRYPTED_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + } catch (error) {} + }); + it('fails to decrypt cardPAN', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .calledOnce + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID); + }); + it('throwing an error', () => { + return expect(encryption.decryptCard).to.have.thrown(); + }); + }); + describe('with an invalid encryption key', () => { + beforeEach(() => { + utilsStub.decryptDataV3 + .onCall(0).returns({ + info: 'invalid encryption key.', + code: 9}); + try { + encryption.decryptCard(MIN_ENCRYPTED_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + } catch (error) {} + }); + it('fails to decrypt cardPAN', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .calledOnce + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID); + }); + it('throwing an error', () => { + return expect(encryption.decryptCard).to.have.returned(null); + }); + }); + describe('to send invalid data to decrypt', () => { + beforeEach(() => { + try { + encryption.decryptCard(INVALID_ACCOUNT.CreditDebitCardInfo, ENCRYPTION_KEY, USER_ID); + } catch (error) {} + }); + it('does not try to decrypt anything', () => { + return expect(utilsStub.decryptDataV3).to.not.have.been.called; + }); + it('throwing an error', () => { + return expect(encryption.decryptCard).to.have.thrown(); + }); + }); + }); + }); + describe('calls decryptCardMaintainingAccount', () => { + describe('successfully', () => { + describe('with required card fields set', () => { + beforeEach(() => { + encryption.decryptCardMaintainingAccount(MIN_ENCRYPTED_ACCOUNT, ENCRYPTION_KEY, USER_ID); + }); + it('decrypting cardPAN and expiry date', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .calledTwice + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID); + }); + it('returning decrypted details ', () => { + return expect(encryption.decryptCardMaintainingAccount).to.have.returned(MIN_DECRYPTED_FULL_ACCOUNT); + }); + }); + describe('with all fields set', () => { + beforeEach(() => { + encryption.decryptCardMaintainingAccount(MAX_ENCRYPTED_ACCOUNT, ENCRYPTION_KEY, USER_ID); + }); + it('decrypting valid from, issue no., cardPAN and expiry date', () => { + return expect(utilsStub.decryptDataV3).to.have.been + .callCount(4) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID) + .calledWith(FAKE_ENCRYPTED_DETAILS, ENCRYPTION_KEY, USER_ID); + }); + it('returning decrypted details ', () => { + return expect(encryption.decryptCardMaintainingAccount).to.have.returned(MAX_DECRYPTED_FULL_ACCOUNT); + }); + }); + }); + describe('with wrong key', () => { + it('returns null', () => { + utilsStub.decryptDataV3.onCall(0).returns( + utilsStub.createError(9, 'Decryption error.') + ); + + return expect(encryption.decryptCardMaintainingAccount( + MIN_ENCRYPTED_ACCOUNT, + 'WRONG KEY', + USER_ID + )).to.be.null; + }); + }); + }); + describe('calls encryptCardMaintainingAccount', () => { + describe('successfully', () => { + describe('with required card fields set', () => { + beforeEach(() => { + encryption.encryptCardMaintainingAccount(_.clone(MIN_DATA), ENCRYPTION_KEY, USER_ID); + }); + it('encrypting cardPAN and expiry date', () => { + return expect(utilsStub.encryptDataV3).to.have.been + .calledTwice + .calledWith(CARD_PAN, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_EXPIRY, ENCRYPTION_KEY, USER_ID); + }); + it('returning encrypted details ', () => { + return expect(encryption.encryptCardMaintainingAccount).to.have.returned(MIN_ENCRYPTED_FULL_ACCOUNT); + }); + }); + describe('with all fields set', () => { + beforeEach(() => { + encryption.encryptCardMaintainingAccount(_.clone(MAX_DATA), ENCRYPTION_KEY, USER_ID); + }); + it('encrypting valid from, issue no., cardPAN and expiry date', () => { + return expect(utilsStub.encryptDataV3).to.have.been + .callCount(4) + .calledWith(CARD_PAN, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_EXPIRY, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_VALID_FROM, ENCRYPTION_KEY, USER_ID) + .calledWith(CARD_ISSUE_NO, ENCRYPTION_KEY, USER_ID); + }); + it('returning encrypted details ', () => { + return expect(encryption.encryptCardMaintainingAccount).to.have.returned(MAX_ENCRYPTED_FULL_ACCOUNT); + }); + }); + }); + }); +}); diff --git a/node_server/utils/specs/postcodes.spec.js b/node_server/utils/specs/postcodes.spec.js new file mode 100644 index 0000000..02b0ac3 --- /dev/null +++ b/node_server/utils/specs/postcodes.spec.js @@ -0,0 +1,137 @@ +/* globals describe, beforeEach, afterEach, it */ +/** + * Unit testing file for the validation code + */ +'use strict'; +// eslint-disable-next-line no-unused-vars +const testGlobals = require('../../tools/test/testGlobals.js'); +const chai = require('chai'); +const rewire = require('rewire'); + +const expect = chai.expect; + +/** + * Use `rewire` instead of require so that we can access private functions for test + */ +const postcodes = rewire('../postcodes.js'); + +/** + * Get the private function using the `rewire`-ed object. + */ +const pafToBridgeAddress = postcodes.__get__('pafToBridgeAddress'); + +/** + * Testcases for testing the conversion from PAF format to our format + */ +const TESTCASES = [ + { + description: 'sub_building_name should be put in BuildingNameFlat', + paf: { + postcode: 'G12 9UY', + postcode_inward: '9UY', + postcode_outward: 'G12', + post_town: 'GLASGOW', + dependant_locality: '', + double_dependant_locality: '', + thoroughfare: 'Hyndland Road', + dependant_thoroughfare: '', + building_number: '31', + building_name: '', + sub_building_name: '0/1', + po_box: '', + department_name: '', + organisation_name: '', + udprn: 9298788, + umprn: '', + postcode_type: 'S', + su_organisation_indicator: '', + delivery_point_suffix: '1A', + line_1: '0/1', + line_2: '31 Hyndland Road', + line_3: '', + premise: '0/1, 31', + longitude: -4.30397743012253, + latitude: 55.8811827343641, + eastings: 255974, + northings: 667736, + country: 'Scotland', + traditional_county: 'Lanarkshire', + administrative_county: '', + postal_county: 'Lanarkshire', + county: 'Lanarkshire', + district: 'Glasgow City', + ward: 'Hillhead' + }, + expect: { + BuildingNameFlat: '0/1', + Address1: '31 Hyndland Road', + Town: 'GLASGOW', + PostCode: 'G12 9UY', + Country: 'United Kingdom' + } + }, + { + description: 'longer addresses should be spread across BuildingNameFlat, Address1, Address2', + paf: { + postcode: 'EH54 7GA', + postcode_inward: '7GA', + postcode_outward: 'EH54', + post_town: 'LIVINGSTON', + dependant_locality: '', + double_dependant_locality: '', + thoroughfare: 'Rosebank', + dependant_thoroughfare: 'The Alba Campus', + building_number: '', + building_name: 'Alba Innovation Centre', + sub_building_name: '', + po_box: '', + department_name: '', + organisation_name: 'Comcarde Ltd', + udprn: 52317380, + umprn: '', + postcode_type: 'S', + su_organisation_indicator: 'Y', + delivery_point_suffix: '1P', + line_1: 'Comcarde Ltd', + line_2: 'Alba Innovation Centre', + line_3: 'The Alba Campus, Rosebank', + premise: 'Alba Innovation Centre', + longitude: -3.54888286304114, + latitude: 55.8726117996675, + eastings: 303182, + northings: 665467, + country: 'Scotland', + traditional_county: 'West Lothian', + administrative_county: '', + postal_county: 'West Lothian', + county: 'West Lothian', + district: 'West Lothian', + ward: 'Livingston South' + }, + expect: { + BuildingNameFlat: 'Comcarde Ltd', + Address1: 'Alba Innovation Centre', + Address2: 'The Alba Campus, Rosebank', + Town: 'LIVINGSTON', + PostCode: 'EH54 7GA', + Country: 'United Kingdom' + } + } +]; + +/** + * Unit test definitions + */ +describe('postcodes', () => { + describe('pafToBridge conversion', () => { + // + // Loop through all the groups of tests cases, adding a describe for each one + // + for (let i = 0; i < TESTCASES.length; ++i) { + const CASE = TESTCASES[i]; + it(CASE.description, () => { + expect(pafToBridgeAddress(CASE.paf)).to.deep.equal(CASE.expect); + }); + } + }); +}); diff --git a/node_server/utils/swaggerUtils.js b/node_server/utils/swaggerUtils.js new file mode 100644 index 0000000..5bb6520 --- /dev/null +++ b/node_server/utils/swaggerUtils.js @@ -0,0 +1,205 @@ +/** + * Support utilities for implementing controllers based on swagger + */ +'use strict'; + +var _ = require('lodash'); +var debug = require('debug')('utils:swagger'); + +module.exports = { + swaggerToMongoProjection: swaggerToMongoProjection, + getNullableFields: getNullableFields, + applyNullableFields: applyNullableFields, + getAndApplyNullableFields: getAndApplyNullableFields +}; + +/** + * Reads the swagger definition of the operation, and converts the items + * to a MongoDB projection. This can handle operations that either return + * a single object, or return an array of objects. + * + * @see {@link: https://docs.mongodb.org/manual/tutorial/project-fields-from-query-results/} + * + * @param {object} operation - the swagger operation description. + * (usually from `req.swagger.operation`) + * @param {bool} includeId - false to explicitly exclude _id + * @param {string} subdocument - include if the fields come from a subdocument + * @param {Object} renames - object containing key-value fields for fields + * that are renamed between the DB (key) and the + * API (value). + * + * @return {object} - object for a MongoDb projection + */ +function swaggerToMongoProjection(operation, includeId, subdocument, renames) { + var projection = { + _id: includeId ? 1 : 0 + }; + var schema = operation.responses['200'].schema; + if (!schema) { + return projection; + } + + // + // Make the subdocument such that we can just attach it to the key name + // in the projection + // + if (subdocument && _.isString(subdocument)) { + subdocument += '.'; + } else { + subdocument = ''; + } + + // + // Invert the renames object so that it is easier to look up later + // + var renamesMap = {}; + _.forEach(renames, function(value, key) { + renamesMap[value] = key; + }); + + // + // Iterate through the properties and add them (renamed if neccessary) + // NOTE: this treats all schemas as arrays so that we can easily handle + // a schema that uses allOf to provide an array of combined items + // + var schemas = [schema]; + if (schema.hasOwnProperty('allOf')) { + schemas = schema.allOf; + } + + for (let i = 0; i < schemas.length; ++i) { + let schema = schemas[i]; + + let properties = {}; + if (schema.hasOwnProperty('items')) { + properties = schema.items.properties; + } else { + properties = schema.properties; + } + _.forEach(properties, _.bind(function(value, key, collection) { + // + // Check if this key should be renamed from the value in the + // API spec to the property name from the database + // + if (renamesMap.hasOwnProperty(key)) { + key = renamesMap[key]; + } + // Set this as a property we want from the db + this[subdocument + key] = 1; + }, projection)); + } + + debug('Projection: ', operation.operationId, projection); + return projection; +} + +/** + * Gets the fields that we may have to manually convert from '' to null. + * That is: type is an array ["null", "string"], and minLength > 0. + * + * @param {object} operation - the swagger operation description. + * (usually from `req.swagger.operation`) + * @returns {array} - array of field names that are nullable + */ +function getNullableFields(operation) { + var nullable = []; + const baseSchema = operation.responses['200'].schema; + if (!baseSchema) { + return nullable; + } + + // + // Iterate through schemas and find all the nullable properties + // NOTE: this treats all schemas as arrays so that we can easily handle + // a schema that uses allOf to provide an array of combined items + // + let schemas = [baseSchema]; + if (baseSchema.hasOwnProperty('allOf')) { + schemas = baseSchema.allOf; + } + + for (let i = 0; i < schemas.length; ++i) { + let schema = schemas[i]; + + var properties = null; + if (schema.hasOwnProperty('items')) { + properties = schema.items.properties; + } else { + properties = schema.properties; + } + _.forEach(properties, _.bind(function(value, key, collection) { + var testValue = {}; + + // + // Merge `allof` parameters if neccessary + // + if (value.hasOwnProperty('allOf')) { + for (var i = 0; i < value.allOf.length; ++i) { + _.assign(testValue, value.allOf[i]); + } + } else { + testValue = value; + } + + // + // Test if this might be a nullable. + // True if this type: ['null', 'string'] and minLength > 0 + // + if ( + testValue.hasOwnProperty('minLength') && + testValue.minLength > 0 && + testValue.hasOwnProperty('type') && + _.isArray(testValue.type) && + testValue.type.indexOf('null') !== -1 && + testValue.type.indexOf('string') !== -1 + ) { + this.push(key); + } + }, nullable)); + } + debug('Nullable fields: ', operation.operationId, nullable); + return nullable; +} + +/** + * Nulls any fields that are in `nullableFields` and have a value of '' (empty string). + * This is used because the database generally defaults to '' for no entry, + * while the swagger API defaults to `null` + * + * @param {Array} nullableFields - array of names for nullable fields + * @param {Object} item - The item to modify + */ +function applyNullableFields(nullableFields, item) { + for (var i = 0; i < nullableFields.length; ++i) { + var field = nullableFields[i]; + if (item.hasOwnProperty(field) && item[field] === '') { + item[field] = null; + } + } +} + +/** + * Convenieance function that runs getNullableFields() then applyNullableFields(). + * It detects if this is an array, and if so applies to all items in the array. + * + * Note: this shouldn't be called inside a tight loop because it will + * unneccesarily recalculate the nullable fields every time. + * + * @param {object} operation - the swagger operation description. + * (usually from `req.swagger.operation`) + + * @param {object|array} item - the item or items to update + */ +function getAndApplyNullableFields(operation, item) { + var fields = getNullableFields(operation); + + if (_.isArray(item)) { + // This is an array so apply to all items + for (var i = 0; i < item.length; ++i) { + applyNullableFields(fields, item[i]); + } + } else { + // Assume this is an object and apply to this object only + applyNullableFields(fields, item); + } +} diff --git a/node_server/utils/templates.js b/node_server/utils/templates.js new file mode 100644 index 0000000..28a21ec --- /dev/null +++ b/node_server/utils/templates.js @@ -0,0 +1,123 @@ +/** + * Support utilities for rendering html pages and emails + * It is based on [pug](https://pugjs.org) - previously jade - and retains + * a similar api to jade.renderFile() but leverages pre-compiled functions for + * performance. + */ +'use strict'; + +const _ = require('lodash'); +const pug = require('pug'); +const Handlebars = require('handlebars/runtime'); +const path = require('path'); + +module.exports = { + initTemplates: initTemplates, + render: render +}; + +/** + * Location for the root templates directory + */ +const PUG_TEMPLATE_DIR = path.join(global.pathPrefix, '..', 'pug'); +const HANDLEBARS_TEMPLATE_DIR = path.join(global.pathPrefix, '..', 'email_templates'); + +/** + * List of templates that we have available for use + */ +const PUG_TEMPLATES = [ + /* HTML templates for Register7. Remove as part of T1469 */ + 'templates/10005_reg_deleted.pug', + 'templates/54_email_not_found.pug', + 'templates/56_mobile_number_not_found.pug', + 'templates/57_association_error.pug', + 'templates/58_fully_registered.pug', + 'templates/undef_database_offline.pug', + + /* Admin notifications */ + 'adminNotifier/identity_check.pug', + 'adminNotifier/credits_low.pug' +]; + +const HANDLEBARS_TEMPLATES = [ + /* Emails */ + 'account-locked', + 'account-recovery', + 'bridge-welcome', + 'device-added', + 'device-locked', + 'device-new-hardware', + 'device-re-registration', + 'email-changed-old', + 'email-changed-new', + 'email-reverted-from', + 'email-reverted-to', + 'invoice-new', + 'invoice-cancelled', + 'invoice-queried', + 'invoice-updated', + 'marketing-generic', + 'password-changed', + 'password-changed-web', + 'pin-reset', + 'thoughtful-enterprises-marketing' +]; + +/** + * Object to store the rendered template functions for later use + */ +var renderedTemplates = {}; + +/** + * Pre-loads and compiles the pug templates into render functions. + */ +function initTemplates() { + // + // Init the pug templates + // + for (let i = 0; i < PUG_TEMPLATES.length; ++i) { + /* Find the full path to the template */ + let templatePath = path.join(PUG_TEMPLATE_DIR, PUG_TEMPLATES[i]); + + /* compile the render function */ + let renderFunc = pug.compileFile(templatePath); + + /* store it in the cache */ + renderedTemplates[PUG_TEMPLATES[i]] = renderFunc; + } + + // + // Init the handlebars templates + // + for (let i = 0; i < HANDLEBARS_TEMPLATES.length; ++i) { + /* Find the full path to the template */ + let templatePath = path.join(HANDLEBARS_TEMPLATE_DIR, HANDLEBARS_TEMPLATES[i]); + + /** + * Require the template. This returns the template render function + */ + let renderFunc = require(templatePath); + + /* store it in the cache */ + renderedTemplates[HANDLEBARS_TEMPLATES[i]] = renderFunc; + } +} + +/** + * Renders the given template using the provided data. Uses the cached render + * functions that are generated by initTemplates() + * + * @param {String} templateName - the name of the template, e.g. `account-locked` + * @param {Object} data - the data to render into the template + * + * @throws {Error} - throws an Error if the template hasn't been loaded + * @returns {String} - the rendered HTML + */ +function render(templateName, data) { + var renderFunc = renderedTemplates[templateName]; + if (!_.isFunction(renderFunc)) { + throw new Error('Template [' + templateName + '] doesn\'t exist.'); + } + + return renderFunc(data); +} diff --git a/node_server/utils/test/logging.spec.js b/node_server/utils/test/logging.spec.js new file mode 100644 index 0000000..ee72263 --- /dev/null +++ b/node_server/utils/test/logging.spec.js @@ -0,0 +1,174 @@ +/** + * @fileOverview Test the logging libraries + */ +'use strict'; + +const Transport = require('winston-transport'); +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const _ = require('lodash'); + +const logging = require('../logging'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); + +const FILENAME = '/some/file/name.js'; +const ID = 'test:logging'; + +const INFO_LOG_STRING = 'String sent to log.info'; +const ERROR_LOG_STRING = 'String sent to log.error'; +const EXTRA_INFO = { + extra1: 1, + extra2: '2' +}; + +const FAKE_IP = '127.0.0.2'; +const REQUEST_ID = 1234; +const USER_ID = '05afcaac4b73658acc79a26d981246978135edadf1'; +const FAKE_REQ = { + ip: FAKE_IP, + bridgeUniqueId: REQUEST_ID, + session: { + data: { + user: USER_ID + } + } +}; + +/** + * Expected results + */ +const EXPECTED_BASIC_LOG = { + meta: { + ip: FAKE_IP, + file: FILENAME, + logId: ID, + reqId: REQUEST_ID, + userId: USER_ID + } +}; + +const EXPECTED_EXTENDED_LOG = _.defaults({}, + EXPECTED_BASIC_LOG, + { + meta: { + _extra1: 1, + _extra2: '2' + } + } +); + +const EXPECTED_INFO_LEVEL = { + level: 'info', + message: INFO_LOG_STRING +}; + +const EXPECTED_ERROR_LEVEL = { + level: 'error', + message: ERROR_LOG_STRING +}; + +/** + * Make a fake log transport with a spy + */ +class SpyTransport extends Transport { + // eslint-disable-next-line class-methods-use-this + log(info, callback) { + // Null transport doesn't do anything + callback(); + } +} +const spyTransport = new SpyTransport(); +sandbox.spy(spyTransport, 'log'); + +/** + * Function to expect that the correct values were called based on the values + * collected by the spy. + * + * @param {Object} expected - the values we expect to be logged + * + * @returns {Promise} - promise for the result of the expectation + */ +function expectLoggedValues(expected) { + return expect(spyTransport.log).to.be + .calledOnce + .calledWith(sinon.match(expected)); +} + +/** + * The tests + */ +describe('Initialised log', () => { + let log; + let fakeTimer; + + before(() => { + /** + * Use fake timers so we can control the timing of the logged timestamp. + */ + fakeTimer = sinon.useFakeTimers(); + + const logger = logging._test.getLogger(); + logger.add(spyTransport); + log = logging(FILENAME, ID); + }); + + after(() => { + /** + * Put real timers back after all the tests are complete. + */ + fakeTimer.restore(); + }); + + beforeEach(() => { + /** + * Create the log anew, and reset any sandbox history for each test. + */ + log = logging(FILENAME, ID); + sandbox.resetHistory(); + }); + + it('has an info() function', () => { + return expect(log) + .to.have.property('info') + .to.be.instanceOf(Function); + }); + + it('has an error() function', () => { + return expect(log) + .to.have.property('error') + .to.be.instanceOf(Function); + }); + + describe('calling info function', () => { + it('with a `req` and a message logs all basic details at info level', () => { + log.info(FAKE_REQ, INFO_LOG_STRING); + + const expected = _.defaults({}, EXPECTED_BASIC_LOG, EXPECTED_INFO_LEVEL); + return expectLoggedValues(expected); + }); + + it('with a `req`, a message, & more data, merges the extra props prefixed with `_` at info level', () => { + log.info(FAKE_REQ, INFO_LOG_STRING, EXTRA_INFO); + const expected = _.defaults({}, EXPECTED_EXTENDED_LOG, EXPECTED_INFO_LEVEL); + return expectLoggedValues(expected); + }); + }); + + describe('calling error function', () => { + it('with a `req` and a message logs all basic details at error level', () => { + log.error(FAKE_REQ, ERROR_LOG_STRING); + const expected = _.defaults({}, EXPECTED_BASIC_LOG, EXPECTED_ERROR_LEVEL); + return expectLoggedValues(expected); + }); + + it('with a `req`, a message, & more data, merges the extra props prefixed with `_` at error level', () => { + log.error(FAKE_REQ, ERROR_LOG_STRING, EXTRA_INFO); + const expected = _.defaults({}, EXPECTED_EXTENDED_LOG, EXPECTED_ERROR_LEVEL); + return expectLoggedValues(expected); + }); + }); +}); diff --git a/node_server/utils/test/mock-request.js b/node_server/utils/test/mock-request.js new file mode 100644 index 0000000..fd7f136 --- /dev/null +++ b/node_server/utils/test/mock-request.js @@ -0,0 +1,68 @@ +/** + * @fileoverview Mock Express Request object with a body that can be read. + */ +const _ = require('lodash'); +const {Readable} = require('stream'); + +/** + * Mock req for getting the request body from. + * This is required because an Express request is a Readable stream, that we + * use in order to get the raw body for hmac calculations. + */ +class MockRequest extends Readable { + /** + * Constructor for MockRequest + * + * @param {Object?} opt - Options object + * @param {string?} opt.mockBody - Mock body for the request + */ + constructor(opt) { + super(opt); + + this._mockReqLengthRead = 0; + this._mockReqTotalLength = 0; + const body = _.get(opt, 'mockBody'); + + if (!_.isUndefined(body)) { + this._mockReqBody = body; + this._mockReqTotalLength = this._mockReqBody.length; + } + + // + // Set appropriate headers + // + this.headers = {}; + if (this._mockReqTotalLength > 0) { + this.headers = { + 'content-length': this._mockReqTotalLength, + 'content-type': 'application/json; charset=utf-8' + }; + } + } + + /** + * Called whenver the readable string needs more bytes. + * We use it to return the next section of our mockBody. + * + * @param {number} size - the number of bytes to read. + */ + _read(size) { + if (this._mockReqLengthRead >= this._mockReqTotalLength) { + this.push(null); + } else { + const remaining = this._mockReqTotalLength - this._mockReqLengthRead; + const toRead = Math.min(remaining, size); + const str = this._mockReqBody.substr(this._mockReqLengthRead, toRead); + const buf = Buffer.from(str, 'utf-8'); + this.push(buf); + this._mockReqLengthRead += toRead; + } + } +} + +/** + * Export the MockRequest + */ +module.exports = { + MockRequest +}; diff --git a/node_server/utils/test/morgan-mongo.spec.js b/node_server/utils/test/morgan-mongo.spec.js new file mode 100644 index 0000000..44903bc --- /dev/null +++ b/node_server/utils/test/morgan-mongo.spec.js @@ -0,0 +1,365 @@ +/** + * @fileOverview Test morgan (activity) logging to mongodb + */ +/* eslint max-nested-callbacks: ["error", 7] */ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const {stdout, stderr} = require('test-console'); +const rewire = require('rewire'); + +const initMorgan = rewire('../init_morgan'); +const mainDBPStub = initMorgan.__get__('mainDBP'); + +const expect = chai.expect; +const sandbox = sinon.createSandbox(); +chai.use(sinonChai); + +const FAKE_ACTIVITY_LOG_COLLECTION = 'FAKE AL COLLECTION'; + +const FAKE_MORGAN_RECORD = 'Some morgan string'; +const FAKE_MORGAN_RECORD_2 = 'A different message'; +const FAKE_MORGAN_RECORD_3 = 'Another different message'; + +/** + * Test responses as if from Mongo + */ +const MONGO_SUCCESS = { + result: { + ok: 1, + n: 1 // eslint-disable-line id-length + } +}; + +/** + * Expected results + */ +const EXPECTED_DB_ENTRY = { + request: FAKE_MORGAN_RECORD +}; +const EXPECTED_DB_ENTRY_2 = { + request: FAKE_MORGAN_RECORD_2 +}; +const EXPECTED_DB_ENTRY_3 = { + request: FAKE_MORGAN_RECORD_3 +}; + +/** + * The tests + */ +describe('Morgan mongo stream', () => { + let streamUnderTest; + let inspectStdOut; + let inspectStdErr; + let output; + + before(() => { + /** + * Fake the important parts of the database + */ + mainDBPStub._mainDB = mainDBPStub.mainDB; + mainDBPStub.mainDB = { + dbOnline: 1, + collectionActivityLog: FAKE_ACTIVITY_LOG_COLLECTION + }; + }); + + after(() => { + /** + * Put the original mainDB stuff back + */ + mainDBPStub.mainDB = mainDBPStub._mainDB; + }); + + beforeEach(() => { + /** + * Create a new stream and stub the mainDB + */ + streamUnderTest = initMorgan.writeableStream(); + sandbox.stub(mainDBPStub, 'addMany').resolves(MONGO_SUCCESS); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('is an object-mode stream', () => { + return expect(streamUnderTest._writableState.objectMode).to.be.true; + }); + + describe('with an online database', () => { + beforeEach(() => { + output = stdout.inspectSync(() => streamUnderTest.write(FAKE_MORGAN_RECORD)); + }); + + it('logs the record to stdout', () => { + return expect(output) + .to.have.lengthOf(1) + .to.include(FAKE_MORGAN_RECORD + '\n'); + }); + + it('writes stream records to the database', () => { + return expect(mainDBPStub.addMany).to.have.been + .calledOnce + .calledWithMatch( + FAKE_ACTIVITY_LOG_COLLECTION, + [sinon.match(EXPECTED_DB_ENTRY)], + {} + ); + }); + + it('adds timestamp to the record stored in the database', () => { + return expect(mainDBPStub.addMany).to.have.been + .calledOnce + .calledWithMatch( + FAKE_ACTIVITY_LOG_COLLECTION, + [ + sinon.match({timestamp: sinon.match.date}) + ], + {} + ); + }); + }); + + describe('with an offline database', () => { + beforeEach(() => { + mainDBPStub.mainDB.dbOnline = 0; + output = stdout.inspectSync(() => streamUnderTest.write(FAKE_MORGAN_RECORD)); + }); + + afterEach(() => { + mainDBPStub.mainDB.dbOnline = 1; + }); + + it('logs the record to stdout', () => { + return expect(output) + .to.have.lengthOf(1) + .to.include(FAKE_MORGAN_RECORD + '\n'); + }); + + it('does not attempt to stream records to the database', () => { + return expect(mainDBPStub.addMany).to.not.have.been.called; + }); + + describe('that stays offline for 1000 records', () => { + beforeEach(() => { + inspectStdErr = stderr.inspect(); + inspectStdOut = stdout.inspect(); + for (let i = 0; i < 999; ++i) { + streamUnderTest.write(String(i)); + } + inspectStdErr.restore(); + inspectStdOut.restore(); + }); + + it('still logs the incoming records to stdout', () => { + return expect(inspectStdOut.output) + .to.have.lengthOf(999) // plus the 1 in the parent section = 1000 + .to.include('0\n') + .to.include('345\n') + .to.include('998\n'); + }); + + it('doesn\'t report any errors', () => { + return expect(inspectStdErr.output) + .to.have.lengthOf(0); + }); + + describe('then recovers', () => { + beforeEach(() => { + mainDBPStub.mainDB.dbOnline = 1; + output = stdout.inspectSync(() => streamUnderTest.write(FAKE_MORGAN_RECORD_2)); + }); + + afterEach(() => { + mainDBPStub.mainDB.dbOnline = 0; + }); + + it('logs the record to stdout', () => { + return expect(output) + .to.include(FAKE_MORGAN_RECORD_2 + '\n'); + }); + + it('writes all records (including ones cached when offline) to the database', () => { + const expectedArray = [ + sinon.match(EXPECTED_DB_ENTRY) + ]; + for (let i = 0; i < 999; ++i) { + expectedArray.push(sinon.match({ + request: String(i) + })); + } + expectedArray.push(sinon.match(EXPECTED_DB_ENTRY_2)); + + return expect(mainDBPStub.addMany).to.have.been + .calledOnce + .calledWithMatch( + FAKE_ACTIVITY_LOG_COLLECTION, + expectedArray + ); + }); + }); + + describe('then receives a 1001st record with offline db', () => { + beforeEach(() => { + inspectStdErr = stderr.inspect(); + inspectStdOut = stdout.inspect(); + streamUnderTest.write(FAKE_MORGAN_RECORD_2); + inspectStdErr.restore(); + inspectStdOut.restore(); + }); + + it('still logs the incoming record to stdout', () => { + return expect(inspectStdOut.output) + .to.have.lengthOf(1) + .to.include(FAKE_MORGAN_RECORD_2 + '\n'); + }); + + it('reports a data loss warning on stderr', () => { + return expect(inspectStdErr.output) + .to.have.lengthOf(1) + .to.include('Activity log buffer exceeded. MESSAGES WILL BE LOST!\n'); + }); + + describe('then recovers', () => { + beforeEach(() => { + mainDBPStub.mainDB.dbOnline = 1; + output = stdout.inspectSync(() => streamUnderTest.write(FAKE_MORGAN_RECORD_3)); + }); + + afterEach(() => { + mainDBPStub.mainDB.dbOnline = 0; + }); + + it('logs the record to stdout', () => { + return expect(output) + .to.include(FAKE_MORGAN_RECORD_3 + '\n'); + }); + + it('writes all cached records and the new record, but not the 1001st one, to the database', () => { + const expectedArray = [ + sinon.match(EXPECTED_DB_ENTRY) + ]; + for (let i = 0; i < 999; ++i) { + expectedArray.push(sinon.match({ + request: String(i) + })); + } + expectedArray.push(sinon.match(EXPECTED_DB_ENTRY_3)); + + return expect(mainDBPStub.addMany).to.have.been + .calledOnce + .calledWithMatch( + FAKE_ACTIVITY_LOG_COLLECTION, + expectedArray + ); + }); + }); + }); + }); + }); + + describe('with a pending database write', () => { + let resolved = false; + let addManyResolve; + let addManyReject; + + beforeEach(() => { + resolved = false; + + // + // Use a promise we control as the fake response to the mainDB call + // so that we can check what happens with requests that happen while + // a db call is pending. + // + const addManyP = new Promise((resolve, reject) => { + addManyResolve = resolve; + addManyReject = reject; + }); + mainDBPStub.addMany.returns(addManyP); + + inspectStdOut = stdout.inspect(); + + // First request to trigger the pending write + streamUnderTest.write(FAKE_MORGAN_RECORD); + + // Second request while the first one is pending + streamUnderTest.write(FAKE_MORGAN_RECORD_2); + + inspectStdOut.restore(); + }); + + afterEach(() => { + // Run the timer to ensure all requests are resolved before the next test + if (!resolved) { + addManyResolve(MONGO_SUCCESS); + resolved = true; + } + }); + + it('logs the pending and subsequent record to stdout', () => { + return expect(inspectStdOut.output) + .to.have.lengthOf(2) + .to.include(FAKE_MORGAN_RECORD + '\n') + .to.include(FAKE_MORGAN_RECORD_2 + '\n'); + }); + + it('does not attempt to stream the subsequent record to the database', () => { + return expect(mainDBPStub.addMany).to.have.been + .calledOnce; // only the pending one + }); + + describe('with another write after the pending one resolves successfully', () => { + it('logs the one buffered during the pending, and the new one', (cb) => { + // Advance the timer to complete the previous request + addManyResolve(MONGO_SUCCESS); + resolved = true; + + // + // Continue after a timeout to allow the thread of the + // pending DB request to complete before we continue. + // + setTimeout(() => { + // New record to trigger the sending of the cached one + the new one + stdout.inspectSync(() => streamUnderTest.write(FAKE_MORGAN_RECORD_3)); + + expect(mainDBPStub.addMany.secondCall).to.have.been + .calledWithMatch( + FAKE_ACTIVITY_LOG_COLLECTION, + [sinon.match(EXPECTED_DB_ENTRY_2), sinon.match(EXPECTED_DB_ENTRY_3)] + ); + cb(); + }); + }); + }); + + describe('with another write after the pending one resolves unsuccessfully', () => { + it('logs the pending record that failed + the one bufferd during pending, and the new one', (cb) => { + addManyReject('Some mongo error'); + resolved = true; + + // + // Continue after a timeout to allow the thread of the + // pending DB request to complete before we continue. + // + setTimeout(() => { + // New record to trigger the sending of the cached one + the new one + stdout.inspectSync(() => streamUnderTest.write(FAKE_MORGAN_RECORD_3)); + + expect(mainDBPStub.addMany.secondCall).to.have.been + .calledWithMatch( + FAKE_ACTIVITY_LOG_COLLECTION, + [ + sinon.match(EXPECTED_DB_ENTRY), + sinon.match(EXPECTED_DB_ENTRY_2), + sinon.match(EXPECTED_DB_ENTRY_3) + ] + ); + cb(); + }, 0); + }); + }); + }); +}); diff --git a/node_server/utils/test/paycodes.spec.js b/node_server/utils/test/paycodes.spec.js new file mode 100644 index 0000000..d454360 --- /dev/null +++ b/node_server/utils/test/paycodes.spec.js @@ -0,0 +1,152 @@ +/** + * @fileOverview Test the paycode generation libraries + */ +'use strict'; +/* eslint max-nested-callbacks: ["error", 7] */ + +const chai = require('chai'); + +const utils = require('../../ComServe/utils.js'); +const paycodes = require('../paycodes.js'); + +const expect = chai.expect; + +const PAYCODE_LENGTH_DEFAULT = 5; +const PAYCODE_BANK_DEFAULT = paycodes.PAYCODE_METHODS[0]; + +const SUCCESSFUL_VALIDATION = utils.createError(10000, 'Success'); + +describe('paycodes', () => { + describe('simplePayCode', () => { + it('generates a 5-character string', () => { + expect(paycodes.simplePayCode()) + .to.be.a('string') + .to.have.lengthOf(PAYCODE_LENGTH_DEFAULT); + }); + + it('generates a string from only the utils.paycodeString characters', () => { + const paycodeStringRegex = new RegExp( + '^[' + utils.paycodeString + ']+$' + ); + expect(paycodes.simplePayCode()) + .to.match(paycodeStringRegex); + }); + + it('generates a different string every time', () => { + const code1 = paycodes.simplePayCode(); + const code2 = paycodes.simplePayCode(); + + expect(code1).to.not.equal(code2); + }); + + it('generates a string that passes paycodeValidate', () => { + const code = paycodes.simplePayCode(); + + expect(paycodes.payCodeValidate(code)) + .to.deep.equal(SUCCESSFUL_VALIDATION); + }); + }); + + describe('payCodeGeneration', () => { + describe('only allows specific lengths to be generated', () => { + it('<5 is INVALID ', () => { + expect(paycodes.payCodeGeneration(utils.paycodeString, 4, PAYCODE_BANK_DEFAULT)) + .to.equal(-1); + }); + + for (let i = 5; i < 9; ++i) { + it(i + ' is VALID ', () => { + expect(paycodes.payCodeGeneration(utils.paycodeString, i, PAYCODE_BANK_DEFAULT)) + .to.be.a('string') + .to.have.lengthOf(i); + }); + } + + it('9 is INVALID ', () => { + expect(paycodes.payCodeGeneration(utils.paycodeString, 9, PAYCODE_BANK_DEFAULT)) + .to.equal(-1); + }); + + it('10 is VALID ', () => { + expect(paycodes.payCodeGeneration(utils.paycodeString, 10, PAYCODE_BANK_DEFAULT)) + .to.be.a('string') + .to.have.lengthOf(10); + }); + + it('>10 is INVALID ', () => { + expect(paycodes.payCodeGeneration(utils.paycodeString, 11, PAYCODE_BANK_DEFAULT)) + .to.equal(-1); + }); + }); + + describe('only allows specific methods', () => { + paycodes.PAYCODE_METHODS.forEach((method) => { + it('"' + method + '" is VALID', () => { + expect(paycodes.payCodeGeneration(utils.paycodeString, PAYCODE_LENGTH_DEFAULT, method)) + .to.be.a('string') + .to.have.lengthOf(PAYCODE_LENGTH_DEFAULT); + }); + }); + + it('other values are INVALID', () => { + expect(paycodes.payCodeGeneration(utils.paycodeString, PAYCODE_LENGTH_DEFAULT, 'other values')) + .to.equal(-1); + }); + }); + + describe('supports a limited range of character sets', () => { + const validCharacterSets = [ + 'paycodeString' + ]; + const invalidCharacterSets = [ + 'generalText', + 'fullAlphaNumeric', + 'alpha', + 'lowerCaseHex', + 'numeric', + 'hexadecimal' + ]; + + validCharacterSets.forEach((charset) => { + const list = utils[charset]; + describe('utils.' + charset + ' is VALID', () => { + it('generates a string of the required length', () => { + expect(paycodes.payCodeGeneration(list, PAYCODE_LENGTH_DEFAULT, PAYCODE_BANK_DEFAULT)) + .to.be.a('string') + .to.have.lengthOf(PAYCODE_LENGTH_DEFAULT); + }); + + it('generates a string from only the provided characters', () => { + const paycodeStringRegex = new RegExp( + '^[' + list + ']+$' + ); + expect(paycodes.payCodeGeneration(list, PAYCODE_LENGTH_DEFAULT, PAYCODE_BANK_DEFAULT)) + .to.match(paycodeStringRegex); + }); + + it('generates a different string every time', () => { + const code1 = paycodes.payCodeGeneration(list, PAYCODE_LENGTH_DEFAULT, PAYCODE_BANK_DEFAULT); + const code2 = paycodes.payCodeGeneration(list, PAYCODE_LENGTH_DEFAULT, PAYCODE_BANK_DEFAULT); + + expect(code1).to.not.equal(code2); + }); + + it('generates a string that passes paycodeValidate', () => { + const code = paycodes.payCodeGeneration(list, PAYCODE_LENGTH_DEFAULT, PAYCODE_BANK_DEFAULT); + + expect(paycodes.payCodeValidate(code)) + .to.deep.equal(SUCCESSFUL_VALIDATION); + }); + }); + }); + + invalidCharacterSets.forEach((charset) => { + const list = utils[charset]; + it.skip('utils.' + charset + ' is INVALID', () => { + expect(paycodes.payCodeGeneration(list, PAYCODE_LENGTH_DEFAULT, PAYCODE_BANK_DEFAULT)) + .to.equal(-1); + }); + }); + }); + }); +}); diff --git a/node_server/utils/tokens.js b/node_server/utils/tokens.js new file mode 100644 index 0000000..2ec77c4 --- /dev/null +++ b/node_server/utils/tokens.js @@ -0,0 +1,142 @@ +/** + * @fileOverview Utils for handling integratin tokens + */ +'use strict'; + +const Q = require('q'); +const debug = require('debug')('utils:tokens'); +const jwt = require('jsonwebtoken'); + +const config = require(global.configFile); +const utils = require(global.pathPrefix + 'utils.js'); +const mainDB = require(global.pathPrefix + 'mainDB.js'); + +const SECRET = require(global.configFile).integrationsTokenSecret; +const ALGORITHMS = ['HS256']; // HMAC + SHA256 only +const ISSUER = 'bridge-v1'; // Issuer string to validate + +const ERRORS = { + TOKEN_INVALID: 'BRIDGE: Token is invalid', + CLIENT_NOT_FOUND: 'BRIDGE: Client not found for token' +}; + +module.exports = { + ERRORS: ERRORS, + + validateToken: validateToken +}; + +/** + * Validates the token, and returns the client the token is for no success + * + * @param {string} token - the token to validate + * @returns {Promise} - Promise for the client the token belongs to + */ +function validateToken(token) { + // + // Check that we have a webtoken using our secret and available algorithms + // NOTE: We ignore expiration validation as (a) this is for long term use + // in a server, and (b) we only use the contents to lookup the merchant + // so a revoked token will be useless irrespective of expiry time + // WARNING: we MUST specifiy the algorithms ourselves to avoid a security issue + // @link https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ + // + const JWT_OPTIONS = { + algorithms: ALGORITHMS, + ignoreExpiration: true, + issuer: ISSUER + }; + + let jwtP = Q.nfcall( + jwt.verify, + token, + SECRET, + JWT_OPTIONS + ).catch((err) => { + debug('Failed to verify token:', err.message); + return Q.reject(ERRORS.TOKEN_INVALID); + }); + + let clientP = jwtP.then((decoded) => { + debug('Token validated', decoded); + + // + // Get the client + // + return getClientFromToken(decoded) + .then((client) => { + if (client) { + debug('Valid client found'); + return Q.resolve({ + client: client, + decoded: decoded + }); + } else { + debug('Client not found from token', decoded); + return Q.reject(ERRORS.CLIENT_NOT_FOUND); + } + }); + }); + + return Q.all([jwtP, clientP]).spread((decoded, client) => client); +} + +/** + * Gets a valid client based on the information in the token. + * We deliberately don't care about the specific reason we don't find the client, + * be it id, token, client status, etc. We don't want to give that information + * away, so it's all "Unauthorized". + * + * @param {Object} decoded - the decoded integration token + * @param {string} decoded.id - the id of the merchant the token is for + * @param {string} decoded.token - the integration token for the merchant + * @return {Promise} - Resolves if a matching client is found + */ +function getClientFromToken(decoded) { + /** + * Look for a client that matches the following criteria: + * 1. ClientID matches the id we have been given in the token + * 2. ClientStatus is active, not suspended, and doesn't have any KYC issues + * 3. Client has accepted the latest EULA + * 4. A token in the `IntegrationsToken` array matches the token we have been given + * 5. The client is an active merchant, and has a valid merchant name + * 6. The client has the feature flag 'token' set + */ + const query = { + ClientID: decoded.id, + ClientStatus: { + /* jshint -W016*/ + $bitsAllSet: utils.ClientEmailVerifiedMask | utils.ClientDetailsMask, + $bitsAllClear: utils.ClientBarredMask | utils.ClientKycIncompleteMask + /* jshint +W016 */ + }, + EULAVersionAccepted: config.EULAVersion, + 'IntegrationTokens.token': decoded.token, + Merchant: { + $elemMatch: { + MerchantStatus: 1, + CompanyAlias: { + $type: 'string', + $ne: '' + } + } + }, + 'FeatureFlags': 'tokens' + }; + + // + // Add a comment as findOne supports it, and this is a complicated query + // which might be worth tracking. + // + const options = { + comment: 'int_security:getClientFromToken' + }; + + return Q.nfcall( + mainDB.findOneObject, + mainDB.collectionClient, + query, + options, + true + ); +} diff --git a/nsp-for-arc.js b/nsp-for-arc.js new file mode 100644 index 0000000..0b83021 --- /dev/null +++ b/nsp-for-arc.js @@ -0,0 +1,41 @@ +/** + * @fileOverview This file provides a wrapper for the `nsp` (node security) + * utility to allow us to check for security issues in packages + * at commit time. + * This wrapper is needed because nsp DOES NOT take the path + * to the package.json, but instead looks for the package.json + * in the current working directory. `arc lint`, however, + * passes the path to the file, and doesn't change the cwd. + * So this wrapper ensures that nsp is run in the correct dir, + * allowing us to test all package.json files that are committed. + */ +const path = require('path'); +const execFileSync = require('child_process').execFileSync; + +/** + * Get the file to lint from the command line. The command line is always + * argv[0] - node exe + * argv[1] - this script + * argv[2] - the file passed on the command line + */ +if (process.argv.length !== 3) { + throw new Error('Must pass exactly 1 file on the command line'); +} + +const filename = process.argv[2]; +const pathname = path.dirname(filename); +const cwd = process.cwd(); +const nspPath = path.resolve(cwd, 'node_modules', '.bin', 'nsp.cmd'); + +/** + * Exec nsp in the correct working directory + */ +const nsp = execFileSync( + nspPath, + ['check', '--output', 'summary', '--warn-only'], + { + cwd: pathname, + maxBuffer: 1000000 + } +); +process.stdout.write(nsp); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7762c9a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2428 @@ +{ + "name": "comcarde-bridge", + "version": "7.6.4", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0-beta.36.tgz", + "integrity": "sha512-sW77BFwJ48YvQp3Gzz5xtAUiXuYOL2aMJKDwiaY3OcvdqBFurtYfOpSa4QrNyDxmOGRFSYzUpabU2m9QrlWE7w==", + "dev": true, + "requires": { + "chalk": "2.3.0", + "esutils": "2.0.2", + "js-tokens": "3.0.2" + } + }, + "@babel/helper-function-name": { + "version": "7.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.0.0-beta.36.tgz", + "integrity": "sha512-/SGPOyifPf20iTrMN+WdlY2MbKa7/o4j7B/4IAsdOusASp2icT+Wcdjf4tjJHaXNX8Pe9bpgVxLNxhRvcf8E5w==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "7.0.0-beta.36", + "@babel/template": "7.0.0-beta.36", + "@babel/types": "7.0.0-beta.36" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-beta.36.tgz", + "integrity": "sha512-vPPcx2vsSoDbcyWr9S3nd0FM3B4hEXnt0p1oKpwa08GwK0fSRxa98MyaRGf8suk8frdQlG1P3mDrz5p/Rr3pbA==", + "dev": true, + "requires": { + "@babel/types": "7.0.0-beta.36" + } + }, + "@babel/template": { + "version": "7.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.0.0-beta.36.tgz", + "integrity": "sha512-mUBi90WRyZ9iVvlWLEdeo8gn/tROyJdjKNC4W5xJTSZL+9MS89rTJSqiaJKXIkxk/YRDL/g/8snrG/O0xl33uA==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-beta.36", + "@babel/types": "7.0.0-beta.36", + "babylon": "7.0.0-beta.36", + "lodash": "4.17.4" + } + }, + "@babel/traverse": { + "version": "7.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.0.0-beta.36.tgz", + "integrity": "sha512-OTUb6iSKVR/98dGThRJ1BiyfwbuX10BVnkz89IpaerjTPRhDfMBfLsqmzxz5MiywUOW4M0Clta0o7rSxkfcuzw==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-beta.36", + "@babel/helper-function-name": "7.0.0-beta.36", + "@babel/types": "7.0.0-beta.36", + "babylon": "7.0.0-beta.36", + "debug": "3.1.0", + "globals": "11.1.0", + "invariant": "2.2.2", + "lodash": "4.17.4" + } + }, + "@babel/types": { + "version": "7.0.0-beta.36", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0-beta.36.tgz", + "integrity": "sha512-PyAORDO9um9tfnrddXgmWN9e6Sq9qxraQIt5ynqBOSXKA5qvK1kUr+Q3nSzKFdzorsiK+oqcUnAFvEoKxv9D+Q==", + "dev": true, + "requires": { + "esutils": "2.0.2", + "lodash": "4.17.4", + "to-fast-properties": "2.0.0" + } + }, + "acorn": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.3.0.tgz", + "integrity": "sha512-Yej+zOJ1Dm/IMZzzj78OntP/r3zHEaKcyNoU2lAaxPtrseM6rF0xwqoz5Q5ysAiED9hTjI2hgtvLXitlCN1/Ug==", + "dev": true + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "dev": true, + "requires": { + "acorn": "3.3.0" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "dev": true + } + } + }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "dev": true, + "requires": { + "co": "4.6.0", + "fast-deep-equal": "1.0.0", + "fast-json-stable-stringify": "2.0.0", + "json-schema-traverse": "0.3.1" + } + }, + "ajv-keywords": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.1.tgz", + "integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=", + "dev": true + }, + "ansi-escapes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.0.0.tgz", + "integrity": "sha512-O/klc27mWNUigtv0F8NJWbLF00OcegQalkqKURWdosW08YZKi4m6CnSUSvIZG1otNJbTWhN01Hhz389DW7mvDQ==", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "argparse": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz", + "integrity": "sha1-c9g7wmP4bpf4zE9rrhsOkKfSLIY=", + "dev": true, + "requires": { + "sprintf-js": "1.0.3" + } + }, + "array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=", + "dev": true + }, + "array-includes": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.0.3.tgz", + "integrity": "sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0=", + "dev": true, + "requires": { + "define-properties": "1.1.2", + "es-abstract": "1.10.0" + } + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "1.0.3" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=", + "dev": true + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "1.1.3", + "esutils": "2.0.2", + "js-tokens": "3.0.2" + }, + "dependencies": { + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "2.2.1", + "escape-string-regexp": "1.0.5", + "has-ansi": "2.0.0", + "strip-ansi": "3.0.1", + "supports-color": "2.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + } + } + }, + "babel-eslint": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-8.2.1.tgz", + "integrity": "sha512-RzdVOyWKQRUnLXhwLk+eKb4oyW+BykZSkpYwFhM4tnfzAG5OWfvG0w/uyzMp5XKEU0jN82+JefHr39bG2+KhRQ==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-beta.36", + "@babel/traverse": "7.0.0-beta.36", + "@babel/types": "7.0.0-beta.36", + "babylon": "7.0.0-beta.36", + "eslint-scope": "3.7.1", + "eslint-visitor-keys": "1.0.0" + } + }, + "babylon": { + "version": "7.0.0-beta.36", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.36.tgz", + "integrity": "sha512-rw4YdadGwajAMMRl6a5swhQ0JCOOFyaYCfJ0AsmNBD8uBD/r4J8mux7wBaqavvFKqUKQYWOzA1Speams4YDzsQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", + "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", + "dev": true, + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha1-/vKNqLgROgoNtEMLC2Rntpcws0o=", + "dev": true + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "dev": true, + "requires": { + "callsites": "0.2.0" + } + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "dev": true + }, + "chalk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.0.tgz", + "integrity": "sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==", + "dev": true, + "requires": { + "ansi-styles": "3.2.0", + "escape-string-regexp": "1.0.5", + "supports-color": "4.5.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz", + "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==", + "dev": true, + "requires": { + "color-convert": "1.9.1" + } + }, + "supports-color": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "dev": true, + "requires": { + "has-flag": "2.0.0" + } + } + } + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "dev": true + }, + "checkstyle-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/checkstyle-formatter/-/checkstyle-formatter-1.0.0.tgz", + "integrity": "sha1-2PXPHW3c9NWOGW1iesofZy77dKY=", + "dev": true, + "requires": { + "xml-escape": "1.1.0" + } + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "dev": true + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "2.0.0" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "color-convert": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz", + "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "comment-parser": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-0.4.2.tgz", + "integrity": "sha1-+lo/eAEwcBFIZtx7jpzzF6ljX3Q=", + "dev": true, + "requires": { + "readable-stream": "2.3.3" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.0.tgz", + "integrity": "sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc=", + "dev": true, + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.3", + "typedarray": "0.0.6" + } + }, + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", + "dev": true + }, + "core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha1-+F4s+b/tKPdzzIs/pcW2m9wC/j8=", + "dev": true, + "requires": { + "buf-compare": "1.0.1", + "is-error": "2.2.1" + } + }, + "core-js": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.3.tgz", + "integrity": "sha1-isw4NFgk8W2DZbfJtCWRaOjtYD4=", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "4.1.1", + "shebang-command": "1.2.0", + "which": "1.3.0" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha1-SgeBR6irV/ag1PVUckPNIvROtOQ=", + "dev": true, + "requires": { + "core-assert": "0.2.1" + } + }, + "define-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", + "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", + "dev": true, + "requires": { + "foreach": "2.0.5", + "object-keys": "1.0.11" + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "5.0.0", + "is-path-cwd": "1.0.0", + "is-path-in-cwd": "1.0.0", + "object-assign": "4.1.1", + "pify": "2.3.0", + "pinkie-promise": "2.0.1", + "rimraf": "2.6.2" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "2.0.2" + } + }, + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "dev": true, + "requires": { + "iconv-lite": "0.4.19" + } + }, + "enhance-visitors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/enhance-visitors/-/enhance-visitors-1.0.0.tgz", + "integrity": "sha1-qpRdBdpGVnKh69OP7i7T2oUY6Vo=", + "dev": true, + "requires": { + "lodash": "4.17.4" + } + }, + "error-ex": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", + "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=", + "dev": true, + "requires": { + "is-arrayish": "0.2.1" + } + }, + "es-abstract": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.10.0.tgz", + "integrity": "sha512-/uh/DhdqIOSkAWifU+8nG78vlQxdLckUdI/sPgy0VhuXi2qJ7T8czBmqIYtLQVpCIFYafChnsRsB5pyb1JdmCQ==", + "dev": true, + "requires": { + "es-to-primitive": "1.1.1", + "function-bind": "1.1.1", + "has": "1.0.1", + "is-callable": "1.1.3", + "is-regex": "1.0.4" + } + }, + "es-to-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.1.1.tgz", + "integrity": "sha1-RTVSSKiJeQNLZ5Lhm7gfK3l13Q0=", + "dev": true, + "requires": { + "is-callable": "1.1.3", + "is-date-object": "1.0.1", + "is-symbol": "1.0.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.15.0.tgz", + "integrity": "sha512-zEO/Z1ZUxIQ+MhDVKkVTUYpIPDTEJLXGMrkID+5v1NeQHtCz6FZikWuFRgxE1Q/RV2V4zVl1u3xmpPADHhMZ6A==", + "dev": true, + "requires": { + "ajv": "5.5.2", + "babel-code-frame": "6.26.0", + "chalk": "2.3.0", + "concat-stream": "1.6.0", + "cross-spawn": "5.1.0", + "debug": "3.1.0", + "doctrine": "2.1.0", + "eslint-scope": "3.7.1", + "eslint-visitor-keys": "1.0.0", + "espree": "3.5.2", + "esquery": "1.0.0", + "esutils": "2.0.2", + "file-entry-cache": "2.0.0", + "functional-red-black-tree": "1.0.1", + "glob": "7.1.2", + "globals": "11.1.0", + "ignore": "3.3.7", + "imurmurhash": "0.1.4", + "inquirer": "3.3.0", + "is-resolvable": "1.0.1", + "js-yaml": "3.10.0", + "json-stable-stringify-without-jsonify": "1.0.1", + "levn": "0.3.0", + "lodash": "4.17.4", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "natural-compare": "1.4.0", + "optionator": "0.8.2", + "path-is-inside": "1.0.2", + "pluralize": "7.0.0", + "progress": "2.0.0", + "require-uncached": "1.0.3", + "semver": "5.4.1", + "strip-ansi": "4.0.0", + "strip-json-comments": "2.0.1", + "table": "4.0.2", + "text-table": "0.2.0" + } + }, + "eslint-config-canonical": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/eslint-config-canonical/-/eslint-config-canonical-9.3.2.tgz", + "integrity": "sha1-YivgL9PdgQ1uLwawcdtLBQgCgH4=", + "dev": true, + "requires": { + "babel-eslint": "8.2.1", + "eslint-plugin-ava": "4.4.0", + "eslint-plugin-babel": "4.1.2", + "eslint-plugin-filenames": "1.2.0", + "eslint-plugin-flowtype": "2.41.0", + "eslint-plugin-import": "2.8.0", + "eslint-plugin-jest": "20.0.3", + "eslint-plugin-jsdoc": "3.3.1", + "eslint-plugin-lodash": "2.5.0", + "eslint-plugin-mocha": "4.11.0", + "eslint-plugin-no-use-extend-native": "0.3.12", + "eslint-plugin-promise": "3.6.0", + "eslint-plugin-react": "7.5.1", + "eslint-plugin-unicorn": "2.1.2" + } + }, + "eslint-import-resolver-node": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz", + "integrity": "sha512-sfmTqJfPSizWu4aymbPr4Iidp5yKm8yDkHp+Ir3YiTHiiDfxh69mOUsmiqW6RZ9zRXFaF64GtYmN7e+8GHBv6Q==", + "dev": true, + "requires": { + "debug": "2.6.9", + "resolve": "1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "eslint-module-utils": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.1.1.tgz", + "integrity": "sha512-jDI/X5l/6D1rRD/3T43q8Qgbls2nq5km5KSqiwlyUbGo5+04fXhMKdCPhjwbqAa6HXWaMxj8Q4hQDIh7IadJQw==", + "dev": true, + "requires": { + "debug": "2.6.9", + "pkg-dir": "1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "eslint-plugin-ava": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-ava/-/eslint-plugin-ava-4.4.0.tgz", + "integrity": "sha1-wYZuH2LnDa8re19gz7xTv+Jnpxc=", + "dev": true, + "requires": { + "arrify": "1.0.1", + "deep-strict-equal": "0.2.0", + "enhance-visitors": "1.0.0", + "espree": "3.5.2", + "espurify": "1.7.0", + "import-modules": "1.1.0", + "multimatch": "2.1.0", + "pkg-up": "2.0.0" + } + }, + "eslint-plugin-babel": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-babel/-/eslint-plugin-babel-4.1.2.tgz", + "integrity": "sha1-eSAqDjV1fdkngJGbIzbx+i/lPB4=", + "dev": true + }, + "eslint-plugin-filenames": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-filenames/-/eslint-plugin-filenames-1.2.0.tgz", + "integrity": "sha1-runByQGJyV0uSZAsFg7O7+zZn1M=", + "dev": true, + "requires": { + "lodash.camelcase": "4.3.0", + "lodash.kebabcase": "4.1.1", + "lodash.snakecase": "4.1.1", + "lodash.upperfirst": "4.3.1" + } + }, + "eslint-plugin-flowtype": { + "version": "2.41.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.41.0.tgz", + "integrity": "sha512-M5X6qu/zvvXQ7flXp9plyBRlNRMQGNl3c+kQmox+m/jpnCZt0txgauxcrBKAVa9LKE/hBnsItJ9BojdmkefAkA==", + "dev": true, + "requires": { + "lodash": "4.17.4" + } + }, + "eslint-plugin-import": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.8.0.tgz", + "integrity": "sha512-Rf7dfKJxZ16QuTgVv1OYNxkZcsu/hULFnC+e+w0Gzi6jMC3guQoWQgxYxc54IDRinlb6/0v5z/PxxIKmVctN+g==", + "dev": true, + "requires": { + "builtin-modules": "1.1.1", + "contains-path": "0.1.0", + "debug": "2.6.9", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "0.3.2", + "eslint-module-utils": "2.1.1", + "has": "1.0.1", + "lodash.cond": "4.5.2", + "minimatch": "3.0.4", + "read-pkg-up": "2.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "doctrine": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", + "dev": true, + "requires": { + "esutils": "2.0.2", + "isarray": "1.0.0" + } + } + } + }, + "eslint-plugin-jest": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-20.0.3.tgz", + "integrity": "sha1-7BXrpqwKtEpn6/bgJnLKnX58uik=", + "dev": true + }, + "eslint-plugin-jsdoc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-3.3.1.tgz", + "integrity": "sha512-bIPBOl5Z1Tv1+U7Sq0DcOydTIpug5zFjefjewxPGEiNootLltTYCvlL0TlfDhjyb+CrJ+4+n4/y8r9tqBtZZ4Q==", + "dev": true, + "requires": { + "comment-parser": "0.4.2", + "lodash": "4.17.4" + } + }, + "eslint-plugin-lodash": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-lodash/-/eslint-plugin-lodash-2.5.0.tgz", + "integrity": "sha512-CmNYc6sriYcPwwyv+wUtj6KowIhg9HygMi8ow1Q8qfDM1Y7WaHgZj/kPpT9tpjTJkTO2+goqXXzJRj43m5Eang==", + "dev": true, + "requires": { + "lodash": "4.17.4" + } + }, + "eslint-plugin-mocha": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-4.11.0.tgz", + "integrity": "sha1-kRk6L1XiCl41l0BUoAidMBmO5Xg=", + "dev": true, + "requires": { + "ramda": "0.24.1" + } + }, + "eslint-plugin-no-use-extend-native": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-use-extend-native/-/eslint-plugin-no-use-extend-native-0.3.12.tgz", + "integrity": "sha1-OtmgDC3yO11/f2vpFVCYWkq3Aeo=", + "dev": true, + "requires": { + "is-get-set-prop": "1.0.0", + "is-js-type": "2.0.0", + "is-obj-prop": "1.0.0", + "is-proto-prop": "1.0.0" + } + }, + "eslint-plugin-promise": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-3.6.0.tgz", + "integrity": "sha512-YQzM6TLTlApAr7Li8vWKR+K3WghjwKcYzY0d2roWap4SLK+kzuagJX/leTetIDWsFcTFnKNJXWupDCD6aZkP2Q==", + "dev": true + }, + "eslint-plugin-react": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.5.1.tgz", + "integrity": "sha512-YGSjB9Qu6QbVTroUZi66pYky3DfoIPLdHQ/wmrBGyBRnwxQsBXAov9j2rpXt/55i8nyMv6IRWJv2s4d4YnduzQ==", + "dev": true, + "requires": { + "doctrine": "2.1.0", + "has": "1.0.1", + "jsx-ast-utils": "2.0.1", + "prop-types": "15.6.0" + } + }, + "eslint-plugin-unicorn": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-2.1.2.tgz", + "integrity": "sha1-md/+n0dzsEvDk1an/r1k3XACdLw=", + "dev": true, + "requires": { + "import-modules": "1.1.0", + "lodash.camelcase": "4.3.0", + "lodash.kebabcase": "4.1.1", + "lodash.snakecase": "4.1.1", + "lodash.upperfirst": "4.3.1" + } + }, + "eslint-scope": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.1.tgz", + "integrity": "sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=", + "dev": true, + "requires": { + "esrecurse": "4.2.0", + "estraverse": "4.2.0" + } + }, + "eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==", + "dev": true + }, + "espree": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.2.tgz", + "integrity": "sha512-sadKeYwaR/aJ3stC2CdvgXu1T16TdYN+qwCpcWbMnGJ8s0zNWemzrvb2GbD4OhmJ/fwpJjudThAlLobGbWZbCQ==", + "dev": true, + "requires": { + "acorn": "5.3.0", + "acorn-jsx": "3.0.1" + } + }, + "esprima": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", + "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==", + "dev": true + }, + "espurify": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/espurify/-/espurify-1.7.0.tgz", + "integrity": "sha1-HFz2y8zDLm9jk4C9T5kfq5up0iY=", + "dev": true, + "requires": { + "core-js": "2.5.3" + } + }, + "esquery": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.0.tgz", + "integrity": "sha1-z7qLV9f7qT8XKYqKAGoEzaE9gPo=", + "dev": true, + "requires": { + "estraverse": "4.2.0" + } + }, + "esrecurse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.0.tgz", + "integrity": "sha1-+pVo2Y04I/mkHZHpAtyrnqblsWM=", + "dev": true, + "requires": { + "estraverse": "4.2.0", + "object-assign": "4.1.1" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "external-editor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.1.0.tgz", + "integrity": "sha512-E44iT5QVOUJBKij4IIV3uvxuNlbKS38Tw1HiupxEIHPv9qtC2PrDYohbXV5U+1jnfIXttny8gUhj+oZvflFlzA==", + "dev": true, + "requires": { + "chardet": "0.4.2", + "iconv-lite": "0.4.19", + "tmp": "0.0.33" + } + }, + "fast-deep-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", + "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fbjs": { + "version": "0.8.16", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz", + "integrity": "sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=", + "dev": true, + "requires": { + "core-js": "1.2.7", + "isomorphic-fetch": "2.2.1", + "loose-envify": "1.3.1", + "object-assign": "4.1.1", + "promise": "7.3.1", + "setimmediate": "1.0.5", + "ua-parser-js": "0.7.17" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=", + "dev": true + } + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "1.0.5" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "dev": true, + "requires": { + "flat-cache": "1.3.0", + "object-assign": "4.1.1" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "2.0.0" + } + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "dev": true, + "requires": { + "circular-json": "0.3.3", + "del": "2.2.2", + "graceful-fs": "4.1.11", + "write": "0.2.1" + } + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-set-props": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-set-props/-/get-set-props-0.1.0.tgz", + "integrity": "sha1-mYR1wXhEVobQsyJG2l3428++jqM=", + "dev": true + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "globals": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.1.0.tgz", + "integrity": "sha512-uEuWt9mqTlPDwSqi+sHjD4nWU/1N+q0fiWI9T1mZpD2UENqX20CFD5T/ziLZvztPaBKl7ZylUi1q6Qfm7E2CiQ==", + "dev": true + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "1.0.2", + "arrify": "1.0.1", + "glob": "7.1.2", + "object-assign": "4.1.1", + "pify": "2.3.0", + "pinkie-promise": "2.0.1" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", + "dev": true + }, + "has": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz", + "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=", + "dev": true, + "requires": { + "function-bind": "1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "hosted-git-info": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.5.0.tgz", + "integrity": "sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg==", + "dev": true + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==", + "dev": true + }, + "ignore": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.7.tgz", + "integrity": "sha512-YGG3ejvBNHRqu0559EOxxNFihD0AjpvHlC/pdGKd3X3ofe+CoJkYazwNJYTNebqpPKN+VVQbh4ZFn1DivMNuHA==", + "dev": true + }, + "import-modules": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/import-modules/-/import-modules-1.1.0.tgz", + "integrity": "sha1-dI23nFzEK7lwHvq0JPiU5yYA6dw=", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "inquirer": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz", + "integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==", + "dev": true, + "requires": { + "ansi-escapes": "3.0.0", + "chalk": "2.3.0", + "cli-cursor": "2.1.0", + "cli-width": "2.2.0", + "external-editor": "2.1.0", + "figures": "2.0.0", + "lodash": "4.17.4", + "mute-stream": "0.0.7", + "run-async": "2.3.0", + "rx-lite": "4.0.8", + "rx-lite-aggregates": "4.0.8", + "string-width": "2.1.1", + "strip-ansi": "4.0.0", + "through": "2.3.8" + } + }, + "invariant": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz", + "integrity": "sha1-nh9WrArNtr8wMwbzOL47IErmA2A=", + "dev": true, + "requires": { + "loose-envify": "1.3.1" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "dev": true, + "requires": { + "builtin-modules": "1.1.1" + } + }, + "is-callable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.3.tgz", + "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI=", + "dev": true + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "dev": true + }, + "is-error": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.1.tgz", + "integrity": "sha1-aEqW2EB2V3yY9M20DG0mpRI78Zw=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-get-set-prop": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-get-set-prop/-/is-get-set-prop-1.0.0.tgz", + "integrity": "sha1-JzGHfk14pqae3M5rudaLB3nnYxI=", + "dev": true, + "requires": { + "get-set-props": "0.1.0", + "lowercase-keys": "1.0.0" + } + }, + "is-js-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-js-type/-/is-js-type-2.0.0.tgz", + "integrity": "sha1-c2FwBtZZtOtHKbunR9KHgt8PfiI=", + "dev": true, + "requires": { + "js-types": "1.0.0" + } + }, + "is-obj-prop": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-obj-prop/-/is-obj-prop-1.0.0.tgz", + "integrity": "sha1-s03nnEULjXxzqyzfZ9yHWtuF+A4=", + "dev": true, + "requires": { + "lowercase-keys": "1.0.0", + "obj-props": "1.1.0" + } + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz", + "integrity": "sha1-ZHdYK4IU1gI0YJRWcAO+ip6sBNw=", + "dev": true, + "requires": { + "is-path-inside": "1.0.1" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "1.0.2" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-proto-prop": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-proto-prop/-/is-proto-prop-1.0.0.tgz", + "integrity": "sha1-s5UflcCJkk+11PzaZUKrPoPisiA=", + "dev": true, + "requires": { + "lowercase-keys": "1.0.0", + "proto-props": "0.2.1" + } + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "dev": true, + "requires": { + "has": "1.0.1" + } + }, + "is-resolvable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.0.1.tgz", + "integrity": "sha512-y5CXYbzvB3jTnWAZH1Nl7ykUWb6T3BcTs56HUruwBf8MhF56n1HWqhDWnVFo8GHrUPDgvUUNVhrc2U8W7iqz5g==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-symbol": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.1.tgz", + "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "dev": true, + "requires": { + "node-fetch": "1.7.3", + "whatwg-fetch": "2.0.3" + } + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "js-types": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/js-types/-/js-types-1.0.0.tgz", + "integrity": "sha1-0kLmSU7Vcq08koCfyL7X92h8vwM=", + "dev": true + }, + "js-yaml": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz", + "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==", + "dev": true, + "requires": { + "argparse": "1.0.9", + "esprima": "4.0.0" + } + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "jsx-ast-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz", + "integrity": "sha1-6AGxs5mF4g//yHtA43SAgOLcrH8=", + "dev": true, + "requires": { + "array-includes": "3.0.3" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2", + "type-check": "0.3.2" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "parse-json": "2.2.0", + "pify": "2.3.0", + "strip-bom": "3.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "2.0.0", + "path-exists": "3.0.0" + } + }, + "lodash": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", + "dev": true + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", + "dev": true + }, + "lodash.cond": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.cond/-/lodash.cond-4.5.2.tgz", + "integrity": "sha1-9HGh2khr5g9quVXRcRVSPdHSVdU=", + "dev": true + }, + "lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha1-hImxyw0p/4gZXM7KRI/21swpXDY=", + "dev": true + }, + "lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha1-OdcUo1NXFHg3rv1ktdy7Fr7Nj40=", + "dev": true + }, + "lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha1-E2Xt9DFIBIHvDRxolXpe2Z1J984=", + "dev": true + }, + "loose-envify": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", + "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", + "dev": true, + "requires": { + "js-tokens": "3.0.2" + } + }, + "lowercase-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.0.tgz", + "integrity": "sha1-TjNms55/VFfjXxMkvfb4jQv8cwY=", + "dev": true + }, + "lru-cache": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz", + "integrity": "sha512-q4spe4KTfsAS1SUHLO0wz8Qiyf1+vMIAgpRYioFYDMNqKfHQbg+AVDH3i4fvpl71/P1L0dBl+fQi+P37UYf0ew==", + "dev": true, + "requires": { + "pseudomap": "1.0.2", + "yallist": "2.1.2" + } + }, + "mimic-fn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.1.0.tgz", + "integrity": "sha1-5md4PZLonb00KBi1IwudYqZyrRg=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "1.1.8" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "multimatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", + "integrity": "sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=", + "dev": true, + "requires": { + "array-differ": "1.0.0", + "array-union": "1.0.2", + "arrify": "1.0.1", + "minimatch": "3.0.4" + } + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "dev": true, + "requires": { + "encoding": "0.1.12", + "is-stream": "1.1.0" + } + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "dev": true, + "requires": { + "hosted-git-info": "2.5.0", + "is-builtin-module": "1.0.0", + "semver": "5.4.1", + "validate-npm-package-license": "3.0.1" + } + }, + "nsp": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/nsp/-/nsp-2.8.1.tgz", + "integrity": "sha512-jvjDg2Gsw4coD/iZ5eQddsDlkvnwMCNnpG05BproSnuG+Gr1bSQMwWMcQeYje+qdDl3XznmhblMPLpZLecTORQ==", + "dev": true, + "requires": { + "chalk": "1.1.3", + "cli-table": "0.3.1", + "cvss": "1.0.2", + "https-proxy-agent": "1.0.0", + "joi": "6.10.1", + "nodesecurity-npm-utils": "5.0.0", + "path-is-absolute": "1.0.1", + "rc": "1.2.1", + "semver": "5.4.1", + "subcommand": "2.1.0", + "wreck": "6.3.0" + }, + "dependencies": { + "agent-base": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-2.1.1.tgz", + "integrity": "sha1-1t4Q1a9hMtW9aSQn1G/FOFOQlMc=", + "dev": true, + "requires": { + "extend": "3.0.1", + "semver": "5.0.3" + }, + "dependencies": { + "semver": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.0.3.tgz", + "integrity": "sha1-d0Zt5YnNXTyV8TiqeLxWmjy10no=", + "dev": true + } + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", + "dev": true, + "requires": { + "hoek": "2.16.3" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "2.2.1", + "escape-string-regexp": "1.0.5", + "has-ansi": "2.0.0", + "strip-ansi": "3.0.1", + "supports-color": "2.0.0" + } + }, + "cli-table": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.1.tgz", + "integrity": "sha1-9TsFJmqLGguTSz0IIebi3FkUriM=", + "dev": true, + "requires": { + "colors": "1.0.3" + } + }, + "cliclopts": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cliclopts/-/cliclopts-1.1.1.tgz", + "integrity": "sha1-aUMcfLWvcjd0sNORG0w3USQxkQ8=", + "dev": true + }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", + "dev": true + }, + "cvss": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cvss/-/cvss-1.0.2.tgz", + "integrity": "sha1-32fpK/EqeW9J6Sh5nI2zunS5/NY=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz", + "integrity": "sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", + "dev": true + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", + "dev": true + }, + "https-proxy-agent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz", + "integrity": "sha1-NffabEjOTdv6JkiRrFk+5f+GceY=", + "dev": true, + "requires": { + "agent-base": "2.1.1", + "debug": "2.6.9", + "extend": "3.0.1" + } + }, + "ini": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz", + "integrity": "sha1-BTfLedr1m1mhpRff9wbIbsA5Fi4=", + "dev": true + }, + "isemail": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-1.2.0.tgz", + "integrity": "sha1-vgPfjMPineTSxd9lASY/H6RZXpo=", + "dev": true + }, + "joi": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-6.10.1.tgz", + "integrity": "sha1-TVDDGAeRIgAP5fFq8f+OGRe3fgY=", + "dev": true, + "requires": { + "hoek": "2.16.3", + "isemail": "1.2.0", + "moment": "2.18.1", + "topo": "1.1.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "moment": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz", + "integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8=", + "dev": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "nodesecurity-npm-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nodesecurity-npm-utils/-/nodesecurity-npm-utils-5.0.0.tgz", + "integrity": "sha1-Baow3jDKjIRcQEjpT9eOXgi1Xtk=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "rc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.1.tgz", + "integrity": "sha1-LgPo5C7kULjLPc5lvhv4l04d/ZU=", + "dev": true, + "requires": { + "deep-extend": "0.4.2", + "ini": "1.3.4", + "minimist": "1.2.0", + "strip-json-comments": "2.0.1" + } + }, + "semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "subcommand": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/subcommand/-/subcommand-2.1.0.tgz", + "integrity": "sha1-XkzspaN3njNlsVEeBfhmh3MC92A=", + "dev": true, + "requires": { + "cliclopts": "1.1.1", + "debug": "2.6.9", + "minimist": "1.2.0", + "xtend": "4.0.1" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "topo": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/topo/-/topo-1.1.0.tgz", + "integrity": "sha1-6ddRYV0buH3IZdsYL6HKCl71NtU=", + "dev": true, + "requires": { + "hoek": "2.16.3" + } + }, + "wreck": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/wreck/-/wreck-6.3.0.tgz", + "integrity": "sha1-oTaXafB7u2LWo3gzanhx/Hc8dAs=", + "dev": true, + "requires": { + "boom": "2.10.1", + "hoek": "2.16.3" + } + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true + } + } + }, + "nsp-formatter-checkstyle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nsp-formatter-checkstyle/-/nsp-formatter-checkstyle-1.0.2.tgz", + "integrity": "sha512-nIXPnTsPSfrdx27nJ0xbWybRDPwxcl90hynYazFWW9yk95iw9Q9ueffqCqrsJRWeCbB6Wn2mpFLL9U0zv8/3DA==", + "dev": true, + "requires": { + "checkstyle-formatter": "1.0.0" + } + }, + "obj-props": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/obj-props/-/obj-props-1.1.0.tgz", + "integrity": "sha1-YmMT+qRCvv1KROmgLDy2vek3tRE=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-keys": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz", + "integrity": "sha1-xUYBd4rVYPEULODgG8yotW0TQm0=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "1.1.0" + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "0.1.3", + "fast-levenshtein": "2.0.6", + "levn": "0.3.0", + "prelude-ls": "1.1.2", + "type-check": "0.3.2", + "wordwrap": "1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "p-limit": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.2.0.tgz", + "integrity": "sha512-Y/OtIaXtUPr4/YpMv1pCL5L5ed0rumAaAeBSj12F+bSlMdys7i8oQF/GUJmfpTS/QoaRrS/k6pma29haJpsMng==", + "dev": true, + "requires": { + "p-try": "1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "1.2.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "1.3.1" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", + "dev": true + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "2.3.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "2.0.4" + } + }, + "pkg-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-1.0.0.tgz", + "integrity": "sha1-ektQio1bstYp1EcFb/TpyTFM89Q=", + "dev": true, + "requires": { + "find-up": "1.1.2" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "2.1.0", + "pinkie-promise": "2.0.1" + } + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "2.0.1" + } + } + } + }, + "pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz", + "integrity": "sha1-yBmscoBZpGHKscOImivjxJoATX8=", + "dev": true, + "requires": { + "find-up": "2.1.0" + } + }, + "pluralize": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", + "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", + "dev": true + }, + "progress": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz", + "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=", + "dev": true + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "requires": { + "asap": "2.0.6" + } + }, + "prop-types": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz", + "integrity": "sha1-zq8IMCL8RrSjX2nhPvda7Q1jmFY=", + "dev": true, + "requires": { + "fbjs": "0.8.16", + "loose-envify": "1.3.1", + "object-assign": "4.1.1" + } + }, + "proto-props": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/proto-props/-/proto-props-0.2.1.tgz", + "integrity": "sha1-XgHcJnWg3pq/p255nfozTW9IP0s=", + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "ramda": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.24.1.tgz", + "integrity": "sha1-w7d1UZfzW43DUCIoJixMkd22uFc=", + "dev": true + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "2.0.0", + "normalize-package-data": "2.4.0", + "path-type": "2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "2.1.0", + "read-pkg": "2.0.0" + } + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "dev": true, + "requires": { + "caller-path": "0.1.0", + "resolve-from": "1.0.1" + } + }, + "resolve": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.5.0.tgz", + "integrity": "sha512-hgoSGrc3pjzAPHNBg+KnFcK2HwlHTs/YrAGUr6qgTVUZmXv1UEXXl0bZNBKMA9fud6lRYFdPGz0xXxycPzmmiw==", + "dev": true, + "requires": { + "path-parse": "1.0.5" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "2.0.1", + "signal-exit": "3.0.2" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "7.1.2" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "2.1.0" + } + }, + "rx-lite": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz", + "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=", + "dev": true + }, + "rx-lite-aggregates": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz", + "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=", + "dev": true, + "requires": { + "rx-lite": "4.0.8" + } + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "dev": true + }, + "semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0" + } + }, + "spdx-correct": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz", + "integrity": "sha1-SzBz2TP/UfORLwOsVRlJikFQ20A=", + "dev": true, + "requires": { + "spdx-license-ids": "1.2.2" + } + }, + "spdx-expression-parse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz", + "integrity": "sha1-m98vIOH0DtRH++JzJmGR/O1RYmw=", + "dev": true + }, + "spdx-license-ids": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz", + "integrity": "sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc=", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0", + "strip-ansi": "4.0.0" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + } + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "table": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/table/-/table-4.0.2.tgz", + "integrity": "sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==", + "dev": true, + "requires": { + "ajv": "5.5.2", + "ajv-keywords": "2.1.1", + "chalk": "2.3.0", + "lodash": "4.17.4", + "slice-ansi": "1.0.0", + "string-width": "2.1.1" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "1.0.2" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "ua-parser-js": { + "version": "0.7.17", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz", + "integrity": "sha512-uRdSdu1oA1rncCQL7sCj8vSyZkgtL7faaw9Tc9rZ3mGgraQ7+Pdx7w5mnOSF3gw9ZNG6oc+KXfkon3bKuROm0g==", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz", + "integrity": "sha1-KAS6vnEq0zeUWaz74kdGqywwP7w=", + "dev": true, + "requires": { + "spdx-correct": "1.0.2", + "spdx-expression-parse": "1.0.4" + } + }, + "whatwg-fetch": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz", + "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=", + "dev": true + }, + "which": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", + "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==", + "dev": true, + "requires": { + "isexe": "2.0.0" + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "dev": true, + "requires": { + "mkdirp": "0.5.1" + } + }, + "xml-escape": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz", + "integrity": "sha1-OQTBQ/qOs6ADDsZG0pAqLxtwbEQ=", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a25374 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "comcarde-bridge", + "version": "7.6.4", + "description": "Overall project for the bridge server", + "main": "node_server/node_server.js", + "repository": "ssh://git@10.0.10.242/diffusion/BS/bridge-server.git", + "author": "admin@comcarde.com", + "license": "UNLICENSED", + "private": true, + "scripts": { + "postinstall": "cd node_server && npm install" + }, + "devDependencies": { + "eslint": "^4.7.2", + "eslint-config-canonical": "^9.3.1", + "nsp": "^2.7.0", + "nsp-formatter-checkstyle": "^1.0.2" + } +} diff --git a/tools/bitbucket-pipeline-scripts/eslint-changes.sh b/tools/bitbucket-pipeline-scripts/eslint-changes.sh new file mode 100644 index 0000000..bf1e113 --- /dev/null +++ b/tools/bitbucket-pipeline-scripts/eslint-changes.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# +# Script to run ESLint against all the files that have changed in this revision +# +ESLINT="$(git rev-parse --show-toplevel)/node_modules/.bin/eslint" +RUN_LINT=true +PASS_LINT=true + +# +# There are two cases for what changes we should be looking for to lint: +# 1. When it's a merge onto master, we want the last commit (the merge), which will contain +# all changes. +# 2. When it's a PR we want all files in the whole branch, as the last commit may only +# be part of the full change. I.e. we want the difference between HEAD and master. +# +BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) +if [[ "$BRANCH_NAME" = "master" ]]; then + # + # Master branch, so just the last commit + # + echo "ESLint: master branch, so checking last commit." + JS_FILES_TO_LINT=$(git diff --name-only --diff-filter=ACM HEAD~1...HEAD | grep ".js$") +else + # + # Dev branch so we want all the commits in the branch + # There's a slight wrinkle in that bitbucket pipelines only clone this specific branch, so we + # have to fetch master first before we can tell where this branch starts + # + echo "ESLint: dev branch, so checking whole branch." + git fetch origin master:master + JS_FILES_TO_LINT=$(git diff --name-only --diff-filter=ACM master...HEAD | grep ".js$") +fi + +# +# Check if we have any JS files to lint +# +if [[ "$JS_FILES_TO_LINT" = "" ]]; then + echo "ESLint: nothing to test" + RUN_LINT=false + PASS_LINT=true +else + echo "ESLint:" + + # Check for eslint + if [[ ! -x "$ESLINT" ]]; then + echo " - Failed to find " "$ESLINT" + echo " - Please install node modules (npm install)" + fi + + # + # Iterate throught the files and run them against ESLint + # + for FILE in $JS_FILES_TO_LINT + do + "$ESLINT" "$FILE" + + if [[ "$?" == 0 ]]; then + echo " - Passed: $FILE" + else + echo " - Failed: $FILE" + PASS_LINT=false + fi + done +fi + +# +# Check the results +# +if ! $RUN_LINT; then + echo "* ESLint: not run" +elif ! $PASS_LINT; then + echo "* ESLint: FAIL!" + exit 1 +else + echo "* ESLint: pass" +fi + +exit 0 \ No newline at end of file diff --git a/tools/git-hooks/pre-commit b/tools/git-hooks/pre-commit new file mode 100644 index 0000000..e885bc5 --- /dev/null +++ b/tools/git-hooks/pre-commit @@ -0,0 +1,150 @@ +#!/bin/sh +# + +# +# Stash unstaged changes so testing is on the staged changes only +# +echo "Stashing changes (to only test the staged changes)" +STASH_NAME="pre-commit-$(date +%s)" +git stash save -q --keep-index $STASH_NAME +if [ $? -eq 0 ]; then + echo " - done" +else + echo " - Stash failed! Aborting." + exit 1 +fi + +# +# ESLINT Testing +# +STAGED_JS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep ".jsx\{0,1\}$") +ESLINT="$(git rev-parse --show-toplevel)/node_modules/.bin/eslint" +RUN_LINT=true +PASS_LINT=true + +if [[ "$STAGED_JS_FILES" = "" ]]; then + echo "ESLint - nothing to test" + RUN_LINT=false + PASS_LINT=true +else + echo "Running ESLint:" + + # Check for eslint + if [[ ! -x "$ESLINT" ]]; then + echo " - Failed to find " "$ESLINT" + echo " - Please install node modules (npm install)" + fi + + for FILE in $STAGED_JS_FILES + do + "$ESLINT" "$FILE" + + if [[ "$?" == 0 ]]; then + echo " - Passed: $FILE" + else + echo " - Failed: $FILE" + PASS_LINT=false + fi + done +fi + +# +# Unit testing +# +GULP="$(git rev-parse --show-toplevel)/node_server/node_modules/.bin/gulp" +RUN_UNIT=true +PASS_UNIT=false + +echo "Running Unit Tests:" + +# Check for gulp +if [[ ! -x "$GULP" ]]; then + echo " - Failed to find " "$GULP" + echo " - Please install node modules (npm install)" +fi + +"$GULP" --cwd ./node_server test + +if [ $? -eq 0 ]; then + PASS_UNIT=true; +fi + +# +# NSP testing +# +STAGED_NPM_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "package\(-lock\)\?\.json$") +NSP="$(git rev-parse --show-toplevel)/node_modules/.bin/nsp" +RUN_NSP=true +PASS_NSP=false + +if [[ "$STAGED_NPM_FILES" = "" ]]; then + echo "NSP - nothing to test" + RUN_NSP=false + PASS_NSP=true +else + + echo "Running NSP:" + + # Check for eslint + if [[ ! -x "$NSP" ]]; then + echo " - Failed to find " "$NSP" + echo " - Please install node modules (npm install)" + fi + + # For simplicity just run NSP in both directories if anything has changed + "$NSP" check + if [ $? -eq 0 ]; then + # Passed the top level. Repeat for the node_server directory + cd node_server + "$NSP" check + if [ $? -eq 0 ]; then + PASS_NSP=true + fi + + # Return back to the main level + cd .. + fi +fi + +# +# Restore the stashed changes +# +echo "Re-applying stashed changes" +git stash pop -q + +# +# Output results and set exit code +# +EXIT_CODE=0 +echo +echo "### Pre-commit testing results ###" +echo + +if ! $RUN_LINT; then + echo "* ESLint: not run" +elif ! $PASS_LINT; then + echo "* ESLint: FAIL!" + EXIT_CODE=1 +else + echo "* ESLint: pass" +fi + +if ! $RUN_UNIT; then + echo "* Unit tests: not run" +elif ! $PASS_UNIT; then + echo "* Unit tests: FAIL!" + EXIT_CODE=1 +else + echo "* Unit tests: pass" +fi + +if ! $RUN_NSP; then + echo "* Node security project: not run" +elif ! $PASS_NSP; then + echo "* Node security project: FAIL!" + EXIT_CODE=1 +else + echo "* Node security project: pass" +fi + +exit $EXIT_CODE