From 57bd6c8e6a33aeac099004ee9433bb8916ecf73c Mon Sep 17 00:00:00 2001 From: Martin Donnelly Date: Sun, 24 Jun 2018 21:15:03 +0100 Subject: [PATCH] init --- .arcconfig | 11 + .arclint | 18 + .eslintrc.js | 100 + .gitattributes | 19 + .gitignore | 13 + .gitmodules | 4 + Dockerfile | 28 + bitbucket-pipelines.yml | 76 + eslint-for-arc.js | 36 + node_server/.jscsrc | 8 + node_server/.jshintrc | 58 + node_server/ComServe/auth-promises.js | 21 + node_server/ComServe/auth.js | 941 ++ node_server/ComServe/config.js | 262 + node_server/ComServe/credorax.js | 288 + node_server/ComServe/hJSON.js | 518 + node_server/ComServe/hJSON/AcceptEULA.js | 72 + node_server/ComServe/hJSON/AddAddress.js | 180 + node_server/ComServe/hJSON/AddCard.js | 336 + node_server/ComServe/hJSON/AddImage.js | 341 + .../ComServe/hJSON/Authorise2FARequest.js | 130 + .../ComServe/hJSON/CancelPaymentRequest.js | 198 + node_server/ComServe/hJSON/ChangePIN.js | 101 + node_server/ComServe/hJSON/ChangePassword.js | 121 + node_server/ComServe/hJSON/ConfirmInvoice.js | 243 + .../ComServe/hJSON/ConfirmTransaction.js | 241 + node_server/ComServe/hJSON/DeleteAccount.js | 97 + node_server/ComServe/hJSON/DeleteAddress.js | 158 + node_server/ComServe/hJSON/DeleteDevice.js | 162 + node_server/ComServe/hJSON/DeleteMessage.js | 132 + node_server/ComServe/hJSON/ElevateSession.js | 102 + node_server/ComServe/hJSON/Get2FARequest.js | 112 + .../ComServe/hJSON/GetClientDetails.js | 67 + node_server/ComServe/hJSON/GetImage.js | 141 + node_server/ComServe/hJSON/GetInvoice.js | 148 + node_server/ComServe/hJSON/GetMessage.js | 121 + .../ComServe/hJSON/GetTransactionDetail.js | 145 + .../ComServe/hJSON/GetTransactionHistory.js | 115 + .../ComServe/hJSON/GetTransactionUpdate.js | 50 + node_server/ComServe/hJSON/IconCache.js | 61 + node_server/ComServe/hJSON/ImageCache.js | 78 + node_server/ComServe/hJSON/KeepAlive.js | 47 + node_server/ComServe/hJSON/ListAccounts.js | 281 + node_server/ComServe/hJSON/ListAddresses.js | 95 + node_server/ComServe/hJSON/ListDevices.js | 93 + node_server/ComServe/hJSON/ListInvoices.js | 149 + node_server/ComServe/hJSON/ListItems.js | 140 + node_server/ComServe/hJSON/ListMessages.js | 120 + node_server/ComServe/hJSON/LogOut1.js | 94 + node_server/ComServe/hJSON/Login1.js | 337 + node_server/ComServe/hJSON/MarkMessage.js | 128 + node_server/ComServe/hJSON/PINReset.js | 242 + node_server/ComServe/hJSON/PayCodeRequest.js | 352 + node_server/ComServe/hJSON/PostCodeLookup.js | 61 + node_server/ComServe/hJSON/RedeemPayCode.js | 87 + .../ComServe/hJSON/RefundTransaction.js | 483 + node_server/ComServe/hJSON/Register1.js | 268 + node_server/ComServe/hJSON/Register2.js | 195 + node_server/ComServe/hJSON/Register4.js | 184 + node_server/ComServe/hJSON/Register6.js | 200 + node_server/ComServe/hJSON/Register7.js | 255 + node_server/ComServe/hJSON/Register8.js | 227 + node_server/ComServe/hJSON/RejectInvoice.js | 184 + node_server/ComServe/hJSON/ReportImage.js | 140 + node_server/ComServe/hJSON/ResumeDevice.js | 140 + node_server/ComServe/hJSON/RotateHMAC.js | 98 + node_server/ComServe/hJSON/SendReport.js | 148 + .../ComServe/hJSON/SetAccountAddress.js | 176 + .../ComServe/hJSON/SetClientDetails.js | 106 + .../ComServe/hJSON/SetDefaultAccount.js | 165 + node_server/ComServe/hJSON/SetDeviceName.js | 92 + node_server/ComServe/hJSON/SuspendDevice.js | 136 + .../hJSON/specs/ElevateSession.spec.js | 322 + .../hJSON/specs/RedeemPaycode.spec.js | 184 + node_server/ComServe/log.js | 68 + node_server/ComServe/mailer-promises.js | 15 + node_server/ComServe/mailer.js | 341 + node_server/ComServe/mainDB-promises.js | 76 + node_server/ComServe/mainDB.js | 2702 +++++ node_server/ComServe/migrations.js | 314 + node_server/ComServe/rate_limit.js | 99 + node_server/ComServe/sms-promises.js | 11 + node_server/ComServe/sms.js | 121 + .../ComServe/specs/mainDB-promises.spec.js | 178 + node_server/ComServe/specs/utils.spec.js | 685 ++ node_server/ComServe/specs/valid.spec.js | 577 + node_server/ComServe/utils.js | 1090 ++ node_server/ComServe/valid.js | 335 + node_server/ComServe/worldpay.js | 170 + node_server/WebApp/defaultCompanyLogo0.png | Bin 0 -> 7371 bytes node_server/WebApp/defaultSelfie.png | Bin 0 -> 7371 bytes node_server/WebApp/favicon.ico | Bin 0 -> 4286 bytes node_server/WebApp/icons/AMEX.png | Bin 0 -> 6453 bytes node_server/WebApp/icons/BRIDGE_MERCHANT.png | Bin 0 -> 13685 bytes node_server/WebApp/icons/CARTEBLEUE.png | Bin 0 -> 7425 bytes node_server/WebApp/icons/DINERS.png | Bin 0 -> 6332 bytes node_server/WebApp/icons/Dankort.png | Bin 0 -> 4847 bytes node_server/WebApp/icons/Diners-Generic.png | Bin 0 -> 4279 bytes node_server/WebApp/icons/Discover-card.png | Bin 0 -> 7076 bytes node_server/WebApp/icons/Electron.png | Bin 0 -> 6146 bytes node_server/WebApp/icons/Generic-card.png | Bin 0 -> 5757 bytes node_server/WebApp/icons/JCB.png | Bin 0 -> 3496 bytes node_server/WebApp/icons/LloydsTSB.png | Bin 0 -> 12887 bytes .../icons/MASTERCARD_CORPORATE_CREDIT.png | Bin 0 -> 7905 bytes .../icons/MASTERCARD_CORPORATE_DEBIT.png | Bin 0 -> 9565 bytes .../WebApp/icons/MASTERCARD_CREDIT.png | Bin 0 -> 6015 bytes node_server/WebApp/icons/MASTERCARD_DEBIT.png | Bin 0 -> 7449 bytes node_server/WebApp/icons/MIR.png | Bin 0 -> 5227 bytes node_server/WebApp/icons/Maestro.png | Bin 0 -> 6712 bytes node_server/WebApp/icons/RBS.png | Bin 0 -> 11814 bytes .../WebApp/icons/VISA_CORPORATE_CREDIT.png | Bin 0 -> 4519 bytes .../WebApp/icons/VISA_CORPORATE_DEBIT.png | Bin 0 -> 5928 bytes node_server/WebApp/icons/VISA_CREDIT.png | Bin 0 -> 4519 bytes node_server/WebApp/icons/VISA_DEBIT.png | Bin 0 -> 5928 bytes node_server/WebApp/icons/bridge-card.png | Bin 0 -> 39626 bytes node_server/WebApp/icons/credorax-account.png | Bin 0 -> 2218 bytes node_server/WebApp/icons/worldpay-account.png | Bin 0 -> 12849 bytes node_server/dev_api/common/HttpError.js | 23 + node_server/dev_api/common/HttpError.spec.js | 38 + node_server/dev_api/common/daoFactory.js | 142 + node_server/dev_api/common/daoFactory.spec.js | 170 + .../dev_api/common/errorDicts/daoFactory.json | 18 + .../common/errorDicts/instruments.json | 42 + node_server/dev_api/common/hashString.js | 25 + node_server/dev_api/common/hashString.spec.js | 32 + .../dev_api/common/instrument/decrypt-card.js | 31 + .../common/instrument/decrypt-card.spec.js | 94 + .../common/instrument/validate-card-data.js | 31 + .../instrument/validate-card-data.spec.js | 65 + node_server/dev_api/config/swagger.json | 947 ++ .../acquirers/common/AcquirerError.js | 21 + .../acquirers/common/AcquirerError.spec.js | 28 + .../acquirers/common/errorDicts/acquirer.json | 130 + .../worldpay/create-merchant/create.js | 42 + .../worldpay/create-merchant/create.spec.js | 101 + .../create-merchant/create/data-mapper.js | 37 + .../create/data-mapper.spec.js | 85 + .../create-merchant/create/encrypt.js | 63 + .../create-merchant/create/encrypt.spec.js | 137 + .../worldpay/pay-directly/errors/Error.js | 52 + .../pay-directly/errors/Error.spec.js | 43 + .../worldpay/pay-directly/payment.js | 128 + .../worldpay/pay-directly/payment.spec.js | 178 + .../worldpay/pay-with-saved-card/payment.js | 69 + .../pay-with-saved-card/payment.spec.js | 174 + .../payment/data-mapper.js | 58 + .../payment/data-mapper.spec.js | 80 + .../recieve-with-saved-merchant/payment.js | 51 + .../payment.spec.js | 127 + .../payment/decrypt.js | 53 + .../payment/decrypt.spec.js | 131 + .../controllers/common/errorHandler.js | 79 + .../controllers/common/errorHandler.spec.js | 960 ++ .../controllers/instruments/cards/create.js | 51 + .../instruments/cards/create.spec.js | 131 + .../instruments/cards/create/data-mapper.js | 80 + .../cards/create/data-mapper.spec.js | 144 + .../instruments/cards/create/encrypt.js | 26 + .../instruments/cards/create/encrypt.spec.js | 28 + .../controllers/instruments/cards/list.js | 91 + .../instruments/cards/list.spec.js | 147 + .../dev_api/controllers/paycodes/create.js | 0 .../controllers/paycodes/create.spec.js | 0 .../paycodes/create/data-mapper.js | 0 .../paycodes/create/data-mapper.spec.js | 0 .../payment_instruments_controller.js | 218 + .../dev_api/controllers/test_controller.js | 29 + .../worldpay_transaction_controller.js | 96 + node_server/dev_api/dev_server.js | 219 + node_server/dev_api/security.js | 118 + .../dev_api/specs/catch-all-path.e2e.spec.js | 69 + ...pay-to-stored-worldpay-account.e2e.spec.js | 553 + .../pay_with_payment_instrument.e2e.spec.js | 263 + .../payment_instruments_add_card.e2e.spec.js | 445 + .../dev_api/specs/rate-limiting.e2e.spec.js | 140 + .../dev_api/specs/security.e2e.spec.js | 150 + node_server/dev_api/specs/security.spec.js | 142 + .../specs/store-worldpay-merchant.e2e.spec.js | 120 + .../specs/worldpay_transaction.e2e.spec.js | 545 + node_server/dev_api/uniqueIdMiddleware.js | 21 + node_server/exitcodes.js | 22 + node_server/gulp.config.js | 194 + node_server/gulpfile.js | 308 + node_server/impl/confirm_transaction.js | 899 ++ node_server/impl/delete_account.js | 181 + node_server/impl/get_transaction_update.js | 269 + node_server/impl/redeem_paycode.js | 344 + node_server/impl/specs/redeem_paycode.spec.js | 1642 +++ .../controllers/clients_controller.js | 329 + .../controllers/payments_controller.js | 784 ++ node_server/integration_api/int_api_server.js | 194 + node_server/integration_api/int_security.js | 94 + .../integration_swagger_def.json | 680 ++ node_server/node_server.js | 948 ++ node_server/package-lock.json | 10078 ++++++++++++++++ node_server/package.json | 89 + node_server/portal-router.js | 124 + node_server/prometheus-router.js | 52 + node_server/pug/adminNotifier/credits_low.pug | 7 + .../pug/adminNotifier/identity_check.pug | 6 + node_server/pug/console.css | 153 + .../pug/errors/54_email_not_found_main.pug | 5 + .../56_mobile_number_not_found_main.pug | 5 + .../pug/errors/57_association_error_main.pug | 5 + .../pug/errors/58_fully_registered_main.pug | 5 + .../errors/undef_database_offline_main.pug | 5 + node_server/pug/footer/left.pug | 3 + node_server/pug/footer/right.pug | 6 + node_server/pug/header/logo.pug | 2 + node_server/pug/header/title.pug | 3 + node_server/pug/main/10005_reg_deleted.pug | 5 + node_server/pug/navigation/nav1.pug | 6 + .../pug/templates/10005_reg_deleted.pug | 19 + .../pug/templates/54_email_not_found.pug | 19 + .../templates/56_mobile_number_not_found.pug | 19 + .../pug/templates/57_association_error.pug | 19 + .../pug/templates/58_fully_registered.pug | 19 + .../pug/templates/undef_database_offline.pug | 19 + node_server/schemas/AcceptEULA.json | 20 + node_server/schemas/AccountCommands.spec.js | 1059 ++ node_server/schemas/AddAddress.json | 117 + node_server/schemas/AddCard.json | 92 + node_server/schemas/AddDevice.json | 65 + node_server/schemas/AddImage.json | 32 + node_server/schemas/Authorise2FARequest.json | 32 + node_server/schemas/CancelPaymentRequest.json | 24 + node_server/schemas/ChangePIN.json | 28 + node_server/schemas/ChangePassword.json | 28 + node_server/schemas/ConfirmInvoice.json | 40 + node_server/schemas/ConfirmTransaction.json | 31 + node_server/schemas/DeleteAccount.json | 24 + node_server/schemas/DeleteAddress.json | 24 + node_server/schemas/DeleteDevice.json | 28 + node_server/schemas/DeleteMessage.json | 24 + node_server/schemas/ElevateSession.json | 28 + node_server/schemas/Get2FARequest.json | 20 + node_server/schemas/GetClientDetails.json | 20 + node_server/schemas/GetImage.json | 24 + node_server/schemas/GetInvoice.json | 24 + node_server/schemas/GetMessage.json | 24 + node_server/schemas/GetTransactionDetail.json | 24 + .../schemas/GetTransactionHistory.json | 41 + node_server/schemas/GetTransactionUpdate.json | 24 + node_server/schemas/IconCache.json | 9 + node_server/schemas/ImageCache.json | 20 + node_server/schemas/ImageCommands.spec.js | 312 + node_server/schemas/InvoiceCommands.spec.js | 268 + node_server/schemas/KeepAlive.json | 20 + node_server/schemas/ListAccounts.json | 24 + node_server/schemas/ListAddresses.json | 20 + node_server/schemas/ListDeletedAccounts.json | 24 + node_server/schemas/ListDevices.json | 20 + node_server/schemas/ListInvoices.json | 36 + node_server/schemas/ListItems.json | 23 + node_server/schemas/ListMessages.json | 38 + node_server/schemas/LogOut1.json | 20 + node_server/schemas/Login1.json | 62 + node_server/schemas/Login1.spec.js | 178 + node_server/schemas/LoginAuth.spec.js | 409 + node_server/schemas/MarkMessage.json | 32 + node_server/schemas/MerchantCommands.spec.js | 65 + node_server/schemas/MessageCommands.spec.js | 203 + node_server/schemas/PINReset.json | 49 + node_server/schemas/PayCodeRequest.json | 32 + node_server/schemas/PaymentCommands.spec.js | 984 ++ node_server/schemas/PostCodeLookup.json | 24 + node_server/schemas/RedeemPayCode.json | 65 + node_server/schemas/RefundTransaction.json | 36 + node_server/schemas/Register1.json | 63 + node_server/schemas/Register2.json | 32 + node_server/schemas/Register3.json | 32 + node_server/schemas/Register4.json | 24 + node_server/schemas/Register6.json | 24 + node_server/schemas/Register7.json | 9 + node_server/schemas/Register7.params.json | 30 + node_server/schemas/Register8.json | 25 + .../schemas/RegistrationCommands.spec.js | 1124 ++ node_server/schemas/RejectInvoice.json | 37 + node_server/schemas/ReportImage.json | 24 + node_server/schemas/ResumeDevice.json | 28 + node_server/schemas/RotateHMAC.json | 20 + node_server/schemas/SetAccountAddress.json | 28 + node_server/schemas/SetClientDetails.json | 88 + node_server/schemas/SetDefaultAccount.json | 24 + node_server/schemas/SetDeviceName.json | 37 + node_server/schemas/SuspendDevice.json | 24 + .../schemas/customKeywords/ensuretrim.js | 35 + .../schemas/customKeywords/ensuretrim.spec.js | 144 + node_server/schemas/customKeywords/maxdp.js | 42 + .../schemas/customKeywords/maxdp.spec.js | 158 + .../schemas/defaultCommandOnly.params.json | 16 + node_server/schemas/defaults.spec.js | 75 + node_server/schemas/definitions.json | 475 + node_server/schemas/testHelpers.js | 209 + node_server/schemas/utils.spec.js | 97 + node_server/schemas/validator.js | 210 + node_server/schemas/validator.spec.js | 93 + .../swagger_api/api_body_middleware.js | 46 + .../swagger_api/api_cors_middleware.js | 220 + node_server/swagger_api/api_definitions.json | 2001 +++ node_server/swagger_api/api_error_handler.js | 161 + .../swagger_api/api_expiry_middleware.js | 75 + node_server/swagger_api/api_responses.json | 138 + node_server/swagger_api/api_security.js | 374 + .../swagger_api/api_security_device.js | 493 + node_server/swagger_api/api_server.js | 299 + node_server/swagger_api/api_swagger_def.json | 2551 ++++ node_server/swagger_api/api_utils.js | 263 + .../controllers/api_accounts_controller.js | 893 ++ .../controllers/api_addresses_controller.js | 548 + .../controllers/api_csp_controller.js | 22 + .../controllers/api_devices_controller.js | 730 ++ .../api_devices_controllers/api_addDevice.js | 559 + .../api_devices_controllers/api_setPin.js | 197 + .../tests/api_addDevice.spec.js | 1251 ++ .../tests/api_setPin.spec.js | 519 + .../controllers/api_invoices_controller.js | 1077 ++ .../controllers/api_items_controller.js | 712 ++ .../controllers/api_login_controller.js | 1204 ++ .../controllers/api_merchant_controller.js | 98 + .../controllers/api_postcodes_controller.js | 37 + .../controllers/api_recovery_controller.js | 1087 ++ .../controllers/api_tokens_controller.js | 345 + .../api_transactions_controller.js | 248 + .../controllers/api_users_controller.js | 1877 +++ .../controllers/api_utils_controller.js | 5 + .../api_versions_controller.js | 25 + .../tests/api_recovery_controller.spec.js | 328 + .../specs/api_body_middleware.spec.js | 147 + .../specs/api_security_device.spec.js | 845 ++ node_server/test/init_mocha.js | 29 + node_server/test/mocha.opts | 3 + node_server/tools/alldocs/alldocs.js | 104 + .../alldocs/templates/adoc-index.handlebars | 28 + node_server/tools/docgen/docgen.js | 344 + .../templates/adoc-definitions.handlebars | 64 + .../docgen/templates/adoc-overview.handlebars | 36 + .../templates/adoc-parameters.handlebars | 14 + .../docgen/templates/adoc-paths.handlebars | 50 + .../templates/adoc-properties-row.handlebars | 1 + .../docgen/templates/adoc-range.handlebars | 12 + .../adoc-response-definitions.handlebars | 37 + .../templates/adoc-responses.handlebars | 7 + .../templates/adoc-schema-or-type.handlebars | 17 + node_server/tools/test/testConfigFile.json | 18 + node_server/tools/test/testGlobals.js | 8 + .../tools/wikiToSchema/wikiToSchema.js | 614 + node_server/tools/wikidocs/wikidocs.js | 531 + node_server/utils/acquirers/acquirer.js | 205 + .../utils/acquirers/acquirer_errors.js | 33 + node_server/utils/acquirers/credorax.js | 141 + node_server/utils/acquirers/demo_acquirer.js | 89 + node_server/utils/acquirers/test_acquirer.js | 103 + .../utils/acquirers/worldpay_acquirer.js | 592 + .../utils/acquirers/worldpay_acquirer.spec.js | 357 + node_server/utils/adminNotifier.js | 142 + node_server/utils/anon.js | 272 + node_server/utils/api_helpers.js | 31 + node_server/utils/client/client.js | 445 + node_server/utils/credentials.js | 309 + node_server/utils/device/device.js | 38 + node_server/utils/device/specs/device.spec.js | 89 + node_server/utils/diligence/diligence.js | 49 + .../utils/diligence/diligence_errors.js | 20 + .../utils/diligence/tracesmart-idu-aml.js | 223 + .../diligence/tracesmart-idu-aml/request.js | 48 + .../tracesmart-idu-aml/requestIDU.js | 33 + .../tracesmart-idu-aml/requestPerson.js | 155 + .../tracesmart-idu-aml/requestServices.js | 58 + node_server/utils/encryption.js | 299 + .../utils/feature-flags/feature-flags.js | 57 + .../utils/feature-flags/feature-flags.spec.js | 76 + node_server/utils/feature-flags/flags-list.js | 14 + node_server/utils/formatting.js | 64 + node_server/utils/hashing.js | 251 + node_server/utils/hashing.spec.js | 125 + node_server/utils/init_morgan.js | 144 + node_server/utils/logging.js | 173 + node_server/utils/paycodes.js | 401 + node_server/utils/postcodes.js | 101 + node_server/utils/promises.js | 142 + node_server/utils/references.js | 222 + node_server/utils/responses.js | 124 + node_server/utils/specs/anon.spec.js | 57 + node_server/utils/specs/encryption.spec.js | 408 + node_server/utils/specs/postcodes.spec.js | 137 + node_server/utils/swaggerUtils.js | 205 + node_server/utils/templates.js | 123 + node_server/utils/test/logging.spec.js | 174 + node_server/utils/test/mock-request.js | 68 + node_server/utils/test/morgan-mongo.spec.js | 365 + node_server/utils/test/paycodes.spec.js | 152 + node_server/utils/tokens.js | 142 + nsp-for-arc.js | 41 + package-lock.json | 2428 ++++ package.json | 19 + .../eslint-changes.sh | 79 + tools/git-hooks/pre-commit | 150 + 398 files changed, 84198 insertions(+) create mode 100644 .arcconfig create mode 100644 .arclint create mode 100644 .eslintrc.js create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Dockerfile create mode 100644 bitbucket-pipelines.yml create mode 100644 eslint-for-arc.js create mode 100644 node_server/.jscsrc create mode 100644 node_server/.jshintrc create mode 100644 node_server/ComServe/auth-promises.js create mode 100644 node_server/ComServe/auth.js create mode 100644 node_server/ComServe/config.js create mode 100644 node_server/ComServe/credorax.js create mode 100644 node_server/ComServe/hJSON.js create mode 100644 node_server/ComServe/hJSON/AcceptEULA.js create mode 100644 node_server/ComServe/hJSON/AddAddress.js create mode 100644 node_server/ComServe/hJSON/AddCard.js create mode 100644 node_server/ComServe/hJSON/AddImage.js create mode 100644 node_server/ComServe/hJSON/Authorise2FARequest.js create mode 100644 node_server/ComServe/hJSON/CancelPaymentRequest.js create mode 100644 node_server/ComServe/hJSON/ChangePIN.js create mode 100644 node_server/ComServe/hJSON/ChangePassword.js create mode 100644 node_server/ComServe/hJSON/ConfirmInvoice.js create mode 100644 node_server/ComServe/hJSON/ConfirmTransaction.js create mode 100644 node_server/ComServe/hJSON/DeleteAccount.js create mode 100644 node_server/ComServe/hJSON/DeleteAddress.js create mode 100644 node_server/ComServe/hJSON/DeleteDevice.js create mode 100644 node_server/ComServe/hJSON/DeleteMessage.js create mode 100644 node_server/ComServe/hJSON/ElevateSession.js create mode 100644 node_server/ComServe/hJSON/Get2FARequest.js create mode 100644 node_server/ComServe/hJSON/GetClientDetails.js create mode 100644 node_server/ComServe/hJSON/GetImage.js create mode 100644 node_server/ComServe/hJSON/GetInvoice.js create mode 100644 node_server/ComServe/hJSON/GetMessage.js create mode 100644 node_server/ComServe/hJSON/GetTransactionDetail.js create mode 100644 node_server/ComServe/hJSON/GetTransactionHistory.js create mode 100644 node_server/ComServe/hJSON/GetTransactionUpdate.js create mode 100644 node_server/ComServe/hJSON/IconCache.js create mode 100644 node_server/ComServe/hJSON/ImageCache.js create mode 100644 node_server/ComServe/hJSON/KeepAlive.js create mode 100644 node_server/ComServe/hJSON/ListAccounts.js create mode 100644 node_server/ComServe/hJSON/ListAddresses.js create mode 100644 node_server/ComServe/hJSON/ListDevices.js create mode 100644 node_server/ComServe/hJSON/ListInvoices.js create mode 100644 node_server/ComServe/hJSON/ListItems.js create mode 100644 node_server/ComServe/hJSON/ListMessages.js create mode 100644 node_server/ComServe/hJSON/LogOut1.js create mode 100644 node_server/ComServe/hJSON/Login1.js create mode 100644 node_server/ComServe/hJSON/MarkMessage.js create mode 100644 node_server/ComServe/hJSON/PINReset.js create mode 100644 node_server/ComServe/hJSON/PayCodeRequest.js create mode 100644 node_server/ComServe/hJSON/PostCodeLookup.js create mode 100644 node_server/ComServe/hJSON/RedeemPayCode.js create mode 100644 node_server/ComServe/hJSON/RefundTransaction.js create mode 100644 node_server/ComServe/hJSON/Register1.js create mode 100644 node_server/ComServe/hJSON/Register2.js create mode 100644 node_server/ComServe/hJSON/Register4.js create mode 100644 node_server/ComServe/hJSON/Register6.js create mode 100644 node_server/ComServe/hJSON/Register7.js create mode 100644 node_server/ComServe/hJSON/Register8.js create mode 100644 node_server/ComServe/hJSON/RejectInvoice.js create mode 100644 node_server/ComServe/hJSON/ReportImage.js create mode 100644 node_server/ComServe/hJSON/ResumeDevice.js create mode 100644 node_server/ComServe/hJSON/RotateHMAC.js create mode 100644 node_server/ComServe/hJSON/SendReport.js create mode 100644 node_server/ComServe/hJSON/SetAccountAddress.js create mode 100644 node_server/ComServe/hJSON/SetClientDetails.js create mode 100644 node_server/ComServe/hJSON/SetDefaultAccount.js create mode 100644 node_server/ComServe/hJSON/SetDeviceName.js create mode 100644 node_server/ComServe/hJSON/SuspendDevice.js create mode 100644 node_server/ComServe/hJSON/specs/ElevateSession.spec.js create mode 100644 node_server/ComServe/hJSON/specs/RedeemPaycode.spec.js create mode 100644 node_server/ComServe/log.js create mode 100644 node_server/ComServe/mailer-promises.js create mode 100644 node_server/ComServe/mailer.js create mode 100644 node_server/ComServe/mainDB-promises.js create mode 100644 node_server/ComServe/mainDB.js create mode 100644 node_server/ComServe/migrations.js create mode 100644 node_server/ComServe/rate_limit.js create mode 100644 node_server/ComServe/sms-promises.js create mode 100644 node_server/ComServe/sms.js create mode 100644 node_server/ComServe/specs/mainDB-promises.spec.js create mode 100644 node_server/ComServe/specs/utils.spec.js create mode 100644 node_server/ComServe/specs/valid.spec.js create mode 100644 node_server/ComServe/utils.js create mode 100644 node_server/ComServe/valid.js create mode 100644 node_server/ComServe/worldpay.js create mode 100644 node_server/WebApp/defaultCompanyLogo0.png create mode 100644 node_server/WebApp/defaultSelfie.png create mode 100644 node_server/WebApp/favicon.ico create mode 100644 node_server/WebApp/icons/AMEX.png create mode 100644 node_server/WebApp/icons/BRIDGE_MERCHANT.png create mode 100644 node_server/WebApp/icons/CARTEBLEUE.png create mode 100644 node_server/WebApp/icons/DINERS.png create mode 100644 node_server/WebApp/icons/Dankort.png create mode 100644 node_server/WebApp/icons/Diners-Generic.png create mode 100644 node_server/WebApp/icons/Discover-card.png create mode 100644 node_server/WebApp/icons/Electron.png create mode 100644 node_server/WebApp/icons/Generic-card.png create mode 100644 node_server/WebApp/icons/JCB.png create mode 100644 node_server/WebApp/icons/LloydsTSB.png create mode 100644 node_server/WebApp/icons/MASTERCARD_CORPORATE_CREDIT.png create mode 100644 node_server/WebApp/icons/MASTERCARD_CORPORATE_DEBIT.png create mode 100644 node_server/WebApp/icons/MASTERCARD_CREDIT.png create mode 100644 node_server/WebApp/icons/MASTERCARD_DEBIT.png create mode 100644 node_server/WebApp/icons/MIR.png create mode 100644 node_server/WebApp/icons/Maestro.png create mode 100644 node_server/WebApp/icons/RBS.png create mode 100644 node_server/WebApp/icons/VISA_CORPORATE_CREDIT.png create mode 100644 node_server/WebApp/icons/VISA_CORPORATE_DEBIT.png create mode 100644 node_server/WebApp/icons/VISA_CREDIT.png create mode 100644 node_server/WebApp/icons/VISA_DEBIT.png create mode 100644 node_server/WebApp/icons/bridge-card.png create mode 100644 node_server/WebApp/icons/credorax-account.png create mode 100644 node_server/WebApp/icons/worldpay-account.png create mode 100644 node_server/dev_api/common/HttpError.js create mode 100644 node_server/dev_api/common/HttpError.spec.js create mode 100644 node_server/dev_api/common/daoFactory.js create mode 100644 node_server/dev_api/common/daoFactory.spec.js create mode 100644 node_server/dev_api/common/errorDicts/daoFactory.json create mode 100644 node_server/dev_api/common/errorDicts/instruments.json create mode 100644 node_server/dev_api/common/hashString.js create mode 100644 node_server/dev_api/common/hashString.spec.js create mode 100644 node_server/dev_api/common/instrument/decrypt-card.js create mode 100644 node_server/dev_api/common/instrument/decrypt-card.spec.js create mode 100644 node_server/dev_api/common/instrument/validate-card-data.js create mode 100644 node_server/dev_api/common/instrument/validate-card-data.spec.js create mode 100644 node_server/dev_api/config/swagger.json create mode 100644 node_server/dev_api/controllers/acquirers/common/AcquirerError.js create mode 100644 node_server/dev_api/controllers/acquirers/common/AcquirerError.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/common/errorDicts/acquirer.json create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/data-mapper.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/create-merchant/create/encrypt.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-directly/errors/Error.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-directly/payment.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/pay-with-saved-card/payment/data-mapper.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment.spec.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.js create mode 100644 node_server/dev_api/controllers/acquirers/worldpay/recieve-with-saved-merchant/payment/decrypt.spec.js create mode 100644 node_server/dev_api/controllers/common/errorHandler.js create mode 100644 node_server/dev_api/controllers/common/errorHandler.spec.js create mode 100644 node_server/dev_api/controllers/instruments/cards/create.js create mode 100644 node_server/dev_api/controllers/instruments/cards/create.spec.js create mode 100644 node_server/dev_api/controllers/instruments/cards/create/data-mapper.js create mode 100644 node_server/dev_api/controllers/instruments/cards/create/data-mapper.spec.js create mode 100644 node_server/dev_api/controllers/instruments/cards/create/encrypt.js create mode 100644 node_server/dev_api/controllers/instruments/cards/create/encrypt.spec.js create mode 100644 node_server/dev_api/controllers/instruments/cards/list.js create mode 100644 node_server/dev_api/controllers/instruments/cards/list.spec.js create mode 100644 node_server/dev_api/controllers/paycodes/create.js create mode 100644 node_server/dev_api/controllers/paycodes/create.spec.js create mode 100644 node_server/dev_api/controllers/paycodes/create/data-mapper.js create mode 100644 node_server/dev_api/controllers/paycodes/create/data-mapper.spec.js create mode 100644 node_server/dev_api/controllers/payment_instruments_controller.js create mode 100644 node_server/dev_api/controllers/test_controller.js create mode 100644 node_server/dev_api/controllers/worldpay_transaction_controller.js create mode 100644 node_server/dev_api/dev_server.js create mode 100644 node_server/dev_api/security.js create mode 100644 node_server/dev_api/specs/catch-all-path.e2e.spec.js create mode 100644 node_server/dev_api/specs/pay-to-stored-worldpay-account.e2e.spec.js create mode 100644 node_server/dev_api/specs/pay_with_payment_instrument.e2e.spec.js create mode 100644 node_server/dev_api/specs/payment_instruments_add_card.e2e.spec.js create mode 100644 node_server/dev_api/specs/rate-limiting.e2e.spec.js create mode 100644 node_server/dev_api/specs/security.e2e.spec.js create mode 100644 node_server/dev_api/specs/security.spec.js create mode 100644 node_server/dev_api/specs/store-worldpay-merchant.e2e.spec.js create mode 100644 node_server/dev_api/specs/worldpay_transaction.e2e.spec.js create mode 100644 node_server/dev_api/uniqueIdMiddleware.js create mode 100644 node_server/exitcodes.js create mode 100644 node_server/gulp.config.js create mode 100644 node_server/gulpfile.js create mode 100644 node_server/impl/confirm_transaction.js create mode 100644 node_server/impl/delete_account.js create mode 100644 node_server/impl/get_transaction_update.js create mode 100644 node_server/impl/redeem_paycode.js create mode 100644 node_server/impl/specs/redeem_paycode.spec.js create mode 100644 node_server/integration_api/controllers/clients_controller.js create mode 100644 node_server/integration_api/controllers/payments_controller.js create mode 100644 node_server/integration_api/int_api_server.js create mode 100644 node_server/integration_api/int_security.js create mode 100644 node_server/integration_api/integration_swagger_def.json create mode 100644 node_server/node_server.js create mode 100644 node_server/package-lock.json create mode 100644 node_server/package.json create mode 100644 node_server/portal-router.js create mode 100644 node_server/prometheus-router.js create mode 100644 node_server/pug/adminNotifier/credits_low.pug create mode 100644 node_server/pug/adminNotifier/identity_check.pug create mode 100644 node_server/pug/console.css create mode 100644 node_server/pug/errors/54_email_not_found_main.pug create mode 100644 node_server/pug/errors/56_mobile_number_not_found_main.pug create mode 100644 node_server/pug/errors/57_association_error_main.pug create mode 100644 node_server/pug/errors/58_fully_registered_main.pug create mode 100644 node_server/pug/errors/undef_database_offline_main.pug create mode 100644 node_server/pug/footer/left.pug create mode 100644 node_server/pug/footer/right.pug create mode 100644 node_server/pug/header/logo.pug create mode 100644 node_server/pug/header/title.pug create mode 100644 node_server/pug/main/10005_reg_deleted.pug create mode 100644 node_server/pug/navigation/nav1.pug create mode 100644 node_server/pug/templates/10005_reg_deleted.pug create mode 100644 node_server/pug/templates/54_email_not_found.pug create mode 100644 node_server/pug/templates/56_mobile_number_not_found.pug create mode 100644 node_server/pug/templates/57_association_error.pug create mode 100644 node_server/pug/templates/58_fully_registered.pug create mode 100644 node_server/pug/templates/undef_database_offline.pug create mode 100644 node_server/schemas/AcceptEULA.json create mode 100644 node_server/schemas/AccountCommands.spec.js create mode 100644 node_server/schemas/AddAddress.json create mode 100644 node_server/schemas/AddCard.json create mode 100644 node_server/schemas/AddDevice.json create mode 100644 node_server/schemas/AddImage.json create mode 100644 node_server/schemas/Authorise2FARequest.json create mode 100644 node_server/schemas/CancelPaymentRequest.json create mode 100644 node_server/schemas/ChangePIN.json create mode 100644 node_server/schemas/ChangePassword.json create mode 100644 node_server/schemas/ConfirmInvoice.json create mode 100644 node_server/schemas/ConfirmTransaction.json create mode 100644 node_server/schemas/DeleteAccount.json create mode 100644 node_server/schemas/DeleteAddress.json create mode 100644 node_server/schemas/DeleteDevice.json create mode 100644 node_server/schemas/DeleteMessage.json create mode 100644 node_server/schemas/ElevateSession.json create mode 100644 node_server/schemas/Get2FARequest.json create mode 100644 node_server/schemas/GetClientDetails.json create mode 100644 node_server/schemas/GetImage.json create mode 100644 node_server/schemas/GetInvoice.json create mode 100644 node_server/schemas/GetMessage.json create mode 100644 node_server/schemas/GetTransactionDetail.json create mode 100644 node_server/schemas/GetTransactionHistory.json create mode 100644 node_server/schemas/GetTransactionUpdate.json create mode 100644 node_server/schemas/IconCache.json create mode 100644 node_server/schemas/ImageCache.json create mode 100644 node_server/schemas/ImageCommands.spec.js create mode 100644 node_server/schemas/InvoiceCommands.spec.js create mode 100644 node_server/schemas/KeepAlive.json create mode 100644 node_server/schemas/ListAccounts.json create mode 100644 node_server/schemas/ListAddresses.json create mode 100644 node_server/schemas/ListDeletedAccounts.json create mode 100644 node_server/schemas/ListDevices.json create mode 100644 node_server/schemas/ListInvoices.json create mode 100644 node_server/schemas/ListItems.json create mode 100644 node_server/schemas/ListMessages.json create mode 100644 node_server/schemas/LogOut1.json create mode 100644 node_server/schemas/Login1.json create mode 100644 node_server/schemas/Login1.spec.js create mode 100644 node_server/schemas/LoginAuth.spec.js create mode 100644 node_server/schemas/MarkMessage.json create mode 100644 node_server/schemas/MerchantCommands.spec.js create mode 100644 node_server/schemas/MessageCommands.spec.js create mode 100644 node_server/schemas/PINReset.json create mode 100644 node_server/schemas/PayCodeRequest.json create mode 100644 node_server/schemas/PaymentCommands.spec.js create mode 100644 node_server/schemas/PostCodeLookup.json create mode 100644 node_server/schemas/RedeemPayCode.json create mode 100644 node_server/schemas/RefundTransaction.json create mode 100644 node_server/schemas/Register1.json create mode 100644 node_server/schemas/Register2.json create mode 100644 node_server/schemas/Register3.json create mode 100644 node_server/schemas/Register4.json create mode 100644 node_server/schemas/Register6.json create mode 100644 node_server/schemas/Register7.json create mode 100644 node_server/schemas/Register7.params.json create mode 100644 node_server/schemas/Register8.json create mode 100644 node_server/schemas/RegistrationCommands.spec.js create mode 100644 node_server/schemas/RejectInvoice.json create mode 100644 node_server/schemas/ReportImage.json create mode 100644 node_server/schemas/ResumeDevice.json create mode 100644 node_server/schemas/RotateHMAC.json create mode 100644 node_server/schemas/SetAccountAddress.json create mode 100644 node_server/schemas/SetClientDetails.json create mode 100644 node_server/schemas/SetDefaultAccount.json create mode 100644 node_server/schemas/SetDeviceName.json create mode 100644 node_server/schemas/SuspendDevice.json create mode 100644 node_server/schemas/customKeywords/ensuretrim.js create mode 100644 node_server/schemas/customKeywords/ensuretrim.spec.js create mode 100644 node_server/schemas/customKeywords/maxdp.js create mode 100644 node_server/schemas/customKeywords/maxdp.spec.js create mode 100644 node_server/schemas/defaultCommandOnly.params.json create mode 100644 node_server/schemas/defaults.spec.js create mode 100644 node_server/schemas/definitions.json create mode 100644 node_server/schemas/testHelpers.js create mode 100644 node_server/schemas/utils.spec.js create mode 100644 node_server/schemas/validator.js create mode 100644 node_server/schemas/validator.spec.js create mode 100644 node_server/swagger_api/api_body_middleware.js create mode 100644 node_server/swagger_api/api_cors_middleware.js create mode 100644 node_server/swagger_api/api_definitions.json create mode 100644 node_server/swagger_api/api_error_handler.js create mode 100644 node_server/swagger_api/api_expiry_middleware.js create mode 100644 node_server/swagger_api/api_responses.json create mode 100644 node_server/swagger_api/api_security.js create mode 100644 node_server/swagger_api/api_security_device.js create mode 100644 node_server/swagger_api/api_server.js create mode 100644 node_server/swagger_api/api_swagger_def.json create mode 100644 node_server/swagger_api/api_utils.js create mode 100644 node_server/swagger_api/controllers/api_accounts_controller.js create mode 100644 node_server/swagger_api/controllers/api_addresses_controller.js create mode 100644 node_server/swagger_api/controllers/api_csp_controller.js create mode 100644 node_server/swagger_api/controllers/api_devices_controller.js create mode 100644 node_server/swagger_api/controllers/api_devices_controllers/api_addDevice.js create mode 100644 node_server/swagger_api/controllers/api_devices_controllers/api_setPin.js create mode 100644 node_server/swagger_api/controllers/api_devices_controllers/tests/api_addDevice.spec.js create mode 100644 node_server/swagger_api/controllers/api_devices_controllers/tests/api_setPin.spec.js create mode 100644 node_server/swagger_api/controllers/api_invoices_controller.js create mode 100644 node_server/swagger_api/controllers/api_items_controller.js create mode 100644 node_server/swagger_api/controllers/api_login_controller.js create mode 100644 node_server/swagger_api/controllers/api_merchant_controller.js create mode 100644 node_server/swagger_api/controllers/api_postcodes_controller.js create mode 100644 node_server/swagger_api/controllers/api_recovery_controller.js create mode 100644 node_server/swagger_api/controllers/api_tokens_controller.js create mode 100644 node_server/swagger_api/controllers/api_transactions_controller.js create mode 100644 node_server/swagger_api/controllers/api_users_controller.js create mode 100644 node_server/swagger_api/controllers/api_utils_controller.js create mode 100644 node_server/swagger_api/controllers/api_utils_controllers/api_versions_controller.js create mode 100644 node_server/swagger_api/controllers/tests/api_recovery_controller.spec.js create mode 100644 node_server/swagger_api/specs/api_body_middleware.spec.js create mode 100644 node_server/swagger_api/specs/api_security_device.spec.js create mode 100644 node_server/test/init_mocha.js create mode 100644 node_server/test/mocha.opts create mode 100644 node_server/tools/alldocs/alldocs.js create mode 100644 node_server/tools/alldocs/templates/adoc-index.handlebars create mode 100644 node_server/tools/docgen/docgen.js create mode 100644 node_server/tools/docgen/templates/adoc-definitions.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-overview.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-parameters.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-paths.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-properties-row.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-range.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-response-definitions.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-responses.handlebars create mode 100644 node_server/tools/docgen/templates/adoc-schema-or-type.handlebars create mode 100644 node_server/tools/test/testConfigFile.json create mode 100644 node_server/tools/test/testGlobals.js create mode 100644 node_server/tools/wikiToSchema/wikiToSchema.js create mode 100644 node_server/tools/wikidocs/wikidocs.js create mode 100644 node_server/utils/acquirers/acquirer.js create mode 100644 node_server/utils/acquirers/acquirer_errors.js create mode 100644 node_server/utils/acquirers/credorax.js create mode 100644 node_server/utils/acquirers/demo_acquirer.js create mode 100644 node_server/utils/acquirers/test_acquirer.js create mode 100644 node_server/utils/acquirers/worldpay_acquirer.js create mode 100644 node_server/utils/acquirers/worldpay_acquirer.spec.js create mode 100644 node_server/utils/adminNotifier.js create mode 100644 node_server/utils/anon.js create mode 100644 node_server/utils/api_helpers.js create mode 100644 node_server/utils/client/client.js create mode 100644 node_server/utils/credentials.js create mode 100644 node_server/utils/device/device.js create mode 100644 node_server/utils/device/specs/device.spec.js create mode 100644 node_server/utils/diligence/diligence.js create mode 100644 node_server/utils/diligence/diligence_errors.js create mode 100644 node_server/utils/diligence/tracesmart-idu-aml.js create mode 100644 node_server/utils/diligence/tracesmart-idu-aml/request.js create mode 100644 node_server/utils/diligence/tracesmart-idu-aml/requestIDU.js create mode 100644 node_server/utils/diligence/tracesmart-idu-aml/requestPerson.js create mode 100644 node_server/utils/diligence/tracesmart-idu-aml/requestServices.js create mode 100644 node_server/utils/encryption.js create mode 100644 node_server/utils/feature-flags/feature-flags.js create mode 100644 node_server/utils/feature-flags/feature-flags.spec.js create mode 100644 node_server/utils/feature-flags/flags-list.js create mode 100644 node_server/utils/formatting.js create mode 100644 node_server/utils/hashing.js create mode 100644 node_server/utils/hashing.spec.js create mode 100644 node_server/utils/init_morgan.js create mode 100644 node_server/utils/logging.js create mode 100644 node_server/utils/paycodes.js create mode 100644 node_server/utils/postcodes.js create mode 100644 node_server/utils/promises.js create mode 100644 node_server/utils/references.js create mode 100644 node_server/utils/responses.js create mode 100644 node_server/utils/specs/anon.spec.js create mode 100644 node_server/utils/specs/encryption.spec.js create mode 100644 node_server/utils/specs/postcodes.spec.js create mode 100644 node_server/utils/swaggerUtils.js create mode 100644 node_server/utils/templates.js create mode 100644 node_server/utils/test/logging.spec.js create mode 100644 node_server/utils/test/mock-request.js create mode 100644 node_server/utils/test/morgan-mongo.spec.js create mode 100644 node_server/utils/test/paycodes.spec.js create mode 100644 node_server/utils/tokens.js create mode 100644 nsp-for-arc.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tools/bitbucket-pipeline-scripts/eslint-changes.sh create mode 100644 tools/git-hooks/pre-commit 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 0000000000000000000000000000000000000000..401cfe40e67de0af883fc1427883a629c0db7c55 GIT binary patch literal 7371 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 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/defaultSelfie.png b/node_server/WebApp/defaultSelfie.png new file mode 100644 index 0000000000000000000000000000000000000000..401cfe40e67de0af883fc1427883a629c0db7c55 GIT binary patch literal 7371 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 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/favicon.ico b/node_server/WebApp/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..151bdf62ee1bc909bb1d0c322db17441f0efc4e7 GIT binary patch literal 4286 zcmeHKM@Vd05Pc@hI;H^?^_x%xLCiZx7lNRof-4b45e0KtxG~^DapA&hZtT1k78cUe(-S>EKhyH^GUeptkb{GR(f;7^^Yf#PjSYH#f2X6PBWiAL z=4Zyl0Nb~>H;&=;^_6aKZ)s#?gc1@G$il+Hs5l}bBIxYwjB|K-dEx8Y+8PxU6p*vC zvq6lerX~U}Y5-phd96L8&&kONt*orbe2~lKh(j>JG5dwYAVQ70!S8Hbv` zw6v6;+uGVvW@aYM&CL{QT0nhJ$GEsS=_~B?`5he{rH!SfC9lo-`FWWKIEAkve%K(F zrKKg#AN;YgvC>yy7~&T;s4a59Jeb0dUVxgS{{IJmLqmhqNO^hrhul!hulOS)BYBPy zBkHWxzdp+!nnzvW+tSkVQ~cyF2!soSY;(JG+1R z8k0ZPn#ch?OU$|Y`Wb&fK!9AQ7Z(@Ych!Dh@nel(%|ln{ciGw5Ch?=kcXf4f{=gU; z8zWa&SBdcze{F3oJv=-xKl-Pqr>FE)=kZzoutCp5-$f149}th5n;YX`{XtXE$JNyp z#mC33=s=GC#`9@>WvL=4aobcHxj6YyhiOixdz;NW0t zZ*S*#U=K|6Y;|AN$A`9o0ekEx`oG)Q*s%UZ48X*CpPilMzKb4=J#u}0opyJ383$Mx z6B85U>+AagQ*c1j*ayK6eB>a0Q(qB7Sy>q!92{^?hyg=ztM_PNVUOzW?j|oUFHJmw zjk5%_4J^d2wTB2>T+vUkZ)|UG%Q=BB_%Xy8B{elwbFHhfpykogQRYU>Jv}`}oomd^ z&B@>2pTGg_4h#(N`a&&`cS%W!!JH8TxS?fe8uNs+SxijKmwxK_#XZhZ!^6XJKCl*` z!O+l9!X78=aK)NQOiUzbSnS{E4cJT7cBbB=9@Ep)9D~r_{{B7<4GmFaV)w(}EKIXjj`?@}^Dc_MtR#q0* z_w@9XHL2!CzBp_3_4V<2!Ib|4ro@4H##+hE&87PKdOlZFS65R;Mh2e&jXl4c!ui|w NKmEV1E9b9!{R1hFYm5K@ literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/AMEX.png b/node_server/WebApp/icons/AMEX.png new file mode 100644 index 0000000000000000000000000000000000000000..054197134ef6e8073da9ca8acd7cf3e63721ab68 GIT binary patch literal 6453 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 Md{ULEkuv@EKPQPq%K!iX literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/BRIDGE_MERCHANT.png b/node_server/WebApp/icons/BRIDGE_MERCHANT.png new file mode 100644 index 0000000000000000000000000000000000000000..b46863483d3a721bbdaf7f672b34ac3f60a55ae9 GIT binary patch literal 13685 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 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/DINERS.png b/node_server/WebApp/icons/DINERS.png new file mode 100644 index 0000000000000000000000000000000000000000..d12101a08b903d5a6aae6214be91f6ea47b6e9d2 GIT binary patch literal 6332 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 H-7)k(N7%eN literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/Diners-Generic.png b/node_server/WebApp/icons/Diners-Generic.png new file mode 100644 index 0000000000000000000000000000000000000000..599a3232186cbcb594209232a82a6fb43223b076 GIT binary patch literal 4279 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 AvH$=8 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/Discover-card.png b/node_server/WebApp/icons/Discover-card.png new file mode 100644 index 0000000000000000000000000000000000000000..ddeff9d68173dd7da3f3631b49b69c87437f541e GIT binary patch literal 7076 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 A;{X5v literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/Electron.png b/node_server/WebApp/icons/Electron.png new file mode 100644 index 0000000000000000000000000000000000000000..bde26f2d5650465dfb43cb270be461b9a98dc2d6 GIT binary patch literal 6146 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 Xa<|~euCo3s^#Qt?Pc>@P93uY**|2be literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/Generic-card.png b/node_server/WebApp/icons/Generic-card.png new file mode 100644 index 0000000000000000000000000000000000000000..db826a397df1abfa004067a82c386efb32bb90e9 GIT binary patch literal 5757 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 Bv^D?$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..28d107cbaf588279fbd7f8c6ba8d3f890028fe5d GIT binary patch literal 7905 zcmV<79v_8#^=;T1;pU z0)(0n2n0eW z+O(;xva<3T)`56D{*{F-(m#h};QIZ30mn7$!xyTntG9K=lM^6Vke}+Owg`sZkEG8C zh+65xT=M7aL(q3HXpeSYvLbaGN#Es~H)lW;eTLnay3%*Wd59tfuK@_tbPmJd!QT>gOYAKZX}fwd&5#9OU6)HL&SyRL z?muz2djdi_L+0oO1SG4j9(LU(*jrkFXbcDhVDzW}2K0k(K!26T7GTKKdzUBJSeNN{ zfFP`qIv9~ntY<5Kvph4v`YK_U^aDz(p(~?MGR?pHP3G-hfDjUpbO!So1Pp>f0;3YX+A%O^PD5bk{y@)4AeIX-G!SB2Vbsq9HqV36 zvXr1|WoJh_I$UWb0cVi1^`uVgJpdt%&JMKeHzD@;bBMkC zI_&iuU`HYxu~DbJl?LxH8MgCB{Z1_U6vp!}BU051fjM78;G|<=?ls&2PrAOMHZZoW z1lIl$M%`NkMKcR&bAZs1$#d>0WXlp5YhQv98UVZh!LVzNrRM014qH~(RM>!UfzY%C zvB#c4^r6SqITDGE`#xXi<;ebxt=j+|(FYzy?5P(}a^4y6pKvsc>b{P)uyuKn%`n!z z2xHa5N;`CPqJS-jx@^jppcdJ-0!Hg<7;Bz|J>(?VgO8)e>VZNBM&Sa&XDZd6_-GL# z*WZf7g2j&GC}iZkc-KKLkMw;@3)+5h8_btqL&;Sa!N1>BrzILbg0bu_7|jbv<^jVlB5_EY7r~r=DeTcdfIaXCK4Yn)O$v+(4-jGk z7!Abkdj!$De+zqiSVa{1sf*>dgwb~8^}ul^f(Ow#Ec53_ql>wTHp5tQE6q|P?BQoR z*9r_2g#`#P2@DV8ROQio9)R6SFa+`>qI5a~mf(JLAbU_FNHCBv)V18UqwWNWsfcff zv4YQ5Hke`YVLF^G9P?2)fDjvR*#yI15We;oKx=FMz#t^iDWgK8Cyz=)0VK+R!mP0o zm?EJ`p6ZoQHnAP%k{e)4wfzr8Avv0b0|+{i*0L3d-12M2{7pc~F1F)dVrQGx2F(7% z#d>tiNyy|TBTTb5#o5#7!rKg8B+(R+COWY9z^)hp$Wb7sqc8wL?O?ZTMf5ih!CKDv zzoaC8bx9|)_>yA`1V*#oSQ>HUb(4G#SaDppt%R}iVc6p?BLe>!m3B^r0SJyzYyLvS zo_^PA`(IL z{zsgJD4&_XdTeL(2lg=>laNVEixvqG4Du3O0C^m^hH@y4)sMlhnoO|fQ8VU9-46(w zi^S5;5r1c1e!(Cl9V=Ccdmyi+X7Uka@@lIMlg_3&0QuGAGEKhueb)Cm(6ctrm32~grsEaZy(V_MbJ^{HuwyYK zL_4VU1e+ZZYoBH|BQ8_?D!9iE&^jmDUc{fbNxoz z@ivBs)i8SLg`q?ORzstTN^)M)NLcXKjwN>#@r9ca`!I}L)+h}65usD5CHfPHJ;%`D zt#UvhOW)hq!)~k7Bf5+09Rh@szA%D46~E20WKj*RUor$8j{L>Dom2M$f+OAf@Dtdw zamAFW$o9;mDt#>=7v}ON;%v*@o zU|CAbcvdNcGp3>9u3xef*Ri~x5KP+?NbH2u@P_y<66%VUYKydM9|5LWT?60vvtSH3 zgmv^5l*kR(>;Hnp;@h3hG_+$Psf3eigG{tzv`e{+DAYAMPf%M8{}y~a?8c7}Uv!JQ zCW~rzkG*#NF0U?7_iKnX>(r>14h>A51apsp?YE6!0Kr)YAaKY`7?qXkn!T-+*Mlg# z_&gYWdpjLV@U%VsN7x%T!N2z;1P`07Hpr-%Kwd+rgi*yF?7RwCZ|sBE$2LK-KYT+6 zt3@+wq;TZbb4@j?U+ zJQuc1oBRB*)~=^xxrYLSjO4_JKXN+G5U?5WPq>Dl+^1uaD!v_w)nl1=0nE|gqZw!* zSO~&~kKpS&1!muA4zMDP?f!?|vRr8gXQ{kt=T_TyA$7Y`iEZ1R&I$zKtKlf7%C?)g zP(}CSXs<7lRhwbL z4?UyySjLyHLg@+LP^KjM<~!`HW|W@H-?1pd|8qb5BZnb)#33j>b`By8EsV-i0*Am` zv;>L9CiwQ+lk!E7SaK&djD#nq+Dj=ug}r4l!&fnR+hO*b&SM4vbQsM~KX!ab?Xk2Q zK33;u^?qs}ruEtVK-mD8L%!)a(T$%F5D~RE(_lH>Jy+TNfN)0yN3>`NvF~}_v5u}Tq$U?~+ zAUHO_+V&-kJ{|AtcK{&AyGe(CwD z(-WUAhb^BzFzE+yG>7kf5N7Wx<{5>O6X&Aj$V1U`?F}#o4OC&H;a>y)UVE!*O`40q z>=}+9vw1VM&1U!p*T@H&fadx1UHE%cxn6}$=v_`>2S6OnzVVmA9C|$c3i7smPEhoM ziVnma_{)?DvDUvrvox6gQ9q>x?E1eu&+JAf;*Z27;z>9l^Otp3FSJv)0zz$L?8{J& zzCZ~h457rD^@z`>8a?A=1qN$#0}|8Of0|8kVhJyQ7!62fk~djTqI<{~KlPTwN(+S_WIo>3N3+xE*Y zyMQCV717u0vHc+v6%~yLjiNcL98T?Z6zom!sgTp?HI622w(8jU2ba?jx-3{UrFO?x)O+y_L@1`~|F|4zbUcBK+{D^czYM zo%c4i%N6ti&V^YqioolyB7($PhNyHDjhdqvO`Jy)^C>`w(~2}vc7i!hKd5N|{TBKM z;f-`qZ>1T#L^-0qiXqI?3`0!Hrg;ocj{tfMCt!!sN1E8Ja}Yw^tRXDg;To#Y-U#3G zfO-kXlJ_mB#KX?}XnXVkFJaY%(?^e-Z9G*wPy5}34w7iIo{;bfS37v2mj z*vHWdG#Pey9gC%Q+4v^feSu)5BMZ$$eDgmXlOfyqeI>9ryur)$PUIn4L)O$Kl%>Y8 zh}`!($J>|r19oEc-S;7lEprWtWh>NLhO7Q# z)Dq6hWyjWfd9BY~?{LU))0PE_J-kFy&;cIsw@aw;Ql6pGqUW4vYo+dP{Zfl$CJga>RCX=P;`5UfkFU;5J!CQ+|v<%>+jA! zg`IgbD;F}Isf5TY7GjP?+L843{LqOe{G;-`g-$9Hr*_!43SzgEP+^12l>_DvJ~E9q_o74 z9xKd^3Ih;A5`vBxhLS5TLfbXhGmdZ0-z$9b0@V^5F$Nm`VeEYI74%nU{L~zQiTSCv zokaDp^X*YTaL!xTI%ZNr+kg8#l`g3;jCNaA9(mq&#ZR4A_$3<12l*Wr!lY>kj)(f; z8!2lla9-=n7s)m`SrV`P5zv2@v*aBg=}cP}oyrta+wgtxfBg^yzgUClo%gAk$b7!v z8Jm|tVyT7FV`bNssE$0YIEps#m%|=$HoE^J55q419Du5e9TciW;Npwn+n=4Y`Yz|&QD0^AY$lQpXXo3aFNQtvaJ_o!#Vn6fhJP6(%5r5{~)sO`V9)gLh{Bq)@Kp&VrgW4V698e^RK=zObj> z4y=0##;V`#_(`GME7L4QS)kXBpA;&98OcmkxPTDSohZARf)|{Hz=_8o`uMYmtB;Ot z%=*zWcs@F&zD6fYaVy4RiW51ZEx3@ma!b%gh9d zT|NvLdl~GJ--WUHJ)q%(G@m7uGEB$kP@g4a**(U>?mZQD^-O2yRQqQM3mXV7?H+(| zNq`|++!%@mjyep+F^8+mvRg`C_K~G)I~G+twq;8_`uBrxV9JL}qq)YE`fV*=h)&=f zV9?hcT(;>Cmx?)&d2Lzr2=st0A1y8Ir&;xfORao;iY9-wdjY~F>6uup!@BqB?f3#| zK5eYb%2%sQk8({VG$|#0ll>R>)5eLfT37ee?g@xg%EvdYGm&x#Gkoy$Ux5BK08yk} zMnymrDT#`JC{hv?0a2tRDgvTNNmK+xk&>tgh$1CX&VZ0_I~D)`_wGqX7Zw3?283l< z?Mp~S+Pz5kT~3q}AU18<6tAqTToecdCdA|U-ilnLT}ghwAF)_$k@R78b#>?Yb19Ss zZ4ZaTH_~K0=JWZGNEGJgjv^(K^p%)-9ydxK=E9S!$*@A9&@%+ZDW+*&%?c(mEt-!4 zCNUEnxC@xx#J+nLIquWzN|X_0!A~-Mp{c3KUsY8FraL8Uk@8D!-!K*XE}y8$dF8LG zVW$+_DiyjF7pX95XZxl&4^gBfDgvTNNmK+xk&>tgh$1CX5fDX6qFewmG#-zS@caFr z^Zbi0l--|vNB;H<|h7r@KMkW+qFS-okpHO_ODB@_v|s zH$pjl2jen9lOE{&&*>Vh9`f zMWz#&VGEDvc&ufCQ+Z71@d%I66#OSCe4-JaV|oCO70z*(eg8cjQc!tNdtoMctyz<`NN8ab-F^tDV^4-R|_hsFuczJp4Jo4Sa z`=w0RiOjq#0sOwaHjB?QkLl6m`$w8~nyS;qR+FP8hZ_X6|F^9|O6W_f0ZNsnUs26fnO!> z@hSIwEc;gSUZU&$HImh;%lis>PfC44)^{cwTx30&0JzQVT$Xtt8%H#Q$@B*D{wF$s zTE+V>u+AAge}G5oCq(-5o_ux|8e|Jo?vEk=+X4=z7qjf^Qsr`q_b-J50{6EJwjBV$ zHhY}O!qs%w6{A*L#nq)9g=8eoJE4*{~`_*=VI<%7b z8K0)9b}9K@V0t#wq3Pt(Bxdz$mLnrsH0qIBoBkyWkC(DOdy1^$i!JS}KrbLf#r{yA zi>*%Q2qswY#{vkR_tMv7gk7aq)no*3NW&*!dX$}dxz-}`eqEiuC3f2#fteo6>kxTg zj}qPq`aGRlcWJ9Dzx<&br@|SPD=F7cGw?_!z06}SpYNMY+X2C6*him!g3L~OsD}^roeZfba~MkCDTgMmr!V+lPBAt2IqJe?tr_-N-a&l<{p7f13*dQgqYB#)QaCgPj1 z2qQ-RRR3WIS&;=aUTOz~=KqGPQ50a8(){M{08# zzOg*Muj{(ed&9%K5*Zvf0$m)oQXbOqZ!wiOa%ags{{BN2dA%~HZUD*J$gK01Oyr+m ze8@V|s(tnff{F0Wo?EWKUurF-hz&K4Wy=2%n4|AsM=-vWQmm`G)0ztc<}C7wLo4M;7%h7Q zW=cm$1ce`IaK@rrjw*>I&t65AlDJhC(pHr&m>w( zz_~&~bdr=UWo=8x>ypQ8WiyonTv?f3$UZnNL%v_K{@0Zp`htAGX|jH>Y!#$Zi2289 z&C(g4JF1fGu506%io-Y0y>983cLhM=?Xz*;Pc6`6A8WP8?edh0Gsx4C`nSws+^>1u zhLjPLZLguU%_L7=MINmrGCt&y#moD9%6kUWbl(H;k?$5ckmC7!LdKnud{9TW<$?QDO1`^!o%@0r;w`gwAq-zjvT{#*#OZchf@4K9W`d6diO*D zf~sVoW^D7`?2L%;0eU3MWJfj?CmLv`KL4xdy4NX`hD!Az22(Z(;Z9>Cy^=CRs(2~0 zOohz&?rZu~=2N`RrQ68Qv)&6+`6%a|Y!d;nIM{oo+^!_w z&vi$>$Gj(`+>dM7vicDohccd{Ln**a+a9T;0KtNTTF7j(Skg#>{s4uLMv_Jk>IcZW zmyQoGi+QSLCX$`nKK+`5L%D{BL>7m#k*j>3FXg+uELR-L&eW@yl*|NkR+%a5@mkY7 znQ|+uswU49OfDkd4qKMk4%f(JmCkonruSTOh&0@1UyNPoPzt#w!~Nc$c-7Ki>1>~< zTAA9F$(R~1A1myw%gPj!|3Loe$r|sXkt=XE|D#;zN&S~~T?(f>q+}+*`uQ^dD+YO1 zrO^ofCp_2N9ZK?MlDS-`sqzlt@k0p{S%x^0JD@?E0ddl^YKYDz35H9(Nz4KlaL zOdwJGHSesPOT2!q7UuWRnuL6R=I^lBdEP%LU5KTpt&bIR#QQgO@xf%WO!)@nU!IP) zE9lqxpChl#4vM@q1vuiUe&%_W&Soe@uJJlel9y#l$XrUzbPwC%`{{Vu zhKDhcnPPD&+rOumhPpCCDWs9$nR*T5I9csXnU`mXYNR^Zab^oaZt_nTft6?*)|U9;R2Y4hauG5cSGy zQm(X#%pshNjwx@K+z2CMNATV-J@3)rxpupD0&hijr~M0Y6h#KgHHOc&10Z;PipQb+ zBuiOtcs(}L)&ectMwWA=``bG))yNx%l37*&k7cDtYX>QFMD zIJepMS literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f2b5f2419d1e29b8c29767425bc2f7cd448c072d GIT binary patch literal 9565 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 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/MASTERCARD_DEBIT.png b/node_server/WebApp/icons/MASTERCARD_DEBIT.png new file mode 100644 index 0000000000000000000000000000000000000000..ea1d4f1b739616cd6d5e411764e24a0d445247f4 GIT binary patch literal 7449 zcmV+!9p>VRP)Py6(@8`@RCodHoq3QQ#eK(rGxM%Jbl>+aKp=F0K!A~jWE)%tFX+G6I9|`!zgvy6Dk;uA42((sS9seHMvh z%4Rq}p^fsg8r4I68tszaq8H@?_hm@aBTwFQnvb|zPAR>vFl4#6t-`!|5~hjD$K z%hjj%k(Qv${|L%#;kSw>6S&S$PcBGX7si*K$+tLYFPHwz()7XpZ4Y(@L#_NGF49Wd zc8BfbP4!c$)H85_)m(QFzk<%aHB8qt9TaksVvbOprQ3D6`ZOc;PkPp>Jvo>P}@ z19q+?E}`v$n{@-T-lL(Y{FCH8T!^jS{}BhEA`mFM_ex1k+)?uURO2~+I!xzU#oW^1 zcf|net3E(FP0OTS1+5-oFkt2W{iK}@BTLUk)RYgglEdrQQbcTzwZw~6efrF^#zPd^M&OO=y}8fh|Vh8F{IT6 zRL+Og4n+mO50I{kccsB}aA|#jXBOP&Ue2p2=rut3m68H`ystP<0KkT-cVeh=XrmJvisQ} zO_k@%6##^P1w8j~Hg+COCq6^EayR0ZIdTEZ+X}#0POn;+AS&+qUoEZGWotzTo$H7T>Tw z>UxUNU1+N@SxK7#P*iN)#ra{*mjq?#$<)tzANLMf1zH+hacrSp{5nQASw6RgV1M(h8e?iCG`Q!1)86_IyvpvYwk<)`*OV|lm!rJ3&|TQ z(7oj|1r64}5d)x*i_kf$r|d2zD4n4rpNBY)BF__us;&u4;wlv>UB#Bv_i|T4a6y?# zMrQX0KmU(94YyMTTGKQh4z)E%?{+FGUlr&>-7-`uV#ENb2n1?@?oyzMw;5hvOUFIS z^;*(S(Q$tk<_S7eM@1Sce$&g?y$W3C>50h~1??(`jGlUKKYz21aOK;{I?b z-&1`mt|n2Ane^m3$8i_;Y$_@+v^Vty*0B8E^tqy92j_Am0m!`Bblx~fsfTuE2ftOk zyhP{!0XF^+B z+e~Z~Q{*mM2ojfIeQ?p2WyY6Ghlpk|`qeqE7 zVVu~LCWbpT%L$W#?Xxqp-gq{swfC0>wdaxK)advj_VbPtDcr8I4z9hPni=irx> znjlg?OZ?GG#2?Q#KlStb^WqPgyvBv2qoYH&QLFSLBGEKn0ny$e?jN3&+;1KefA2x@ z8qbQ~a!!2WMZwe6Sv@z=JZypvtgjObu4IjeD=oWRlIz!qz4S7%M%DLxV`*&u9AJ4# zbO76FhOZJ-&iMQ_nx<%K9Vtqcy z^gn%3oLMk{-Csep(2h#=n*nJAh1h5qb#uj^{}WFn&yX9}WM?gvCD*ki985;{@T@0D%?FaF8X z()o>Vi+`r=Lhp|S{Kox7M4i$5!YPY+Yjb%?UeKPeX z*rM5Rw%RS|sxMLwq;pr^;iK4jHeiWm@-xbyz|i?k4g(VXU2^zHSMQ`R9){ySW=+U2 zh{0_W>*Qn5>?jS=DV*p+q#Vd+8o|}62_{7&4N$>v_a*lnGYil4KH9Q$QvDK3YTm=U zSB6tn<5{eRE!c*3;cbAWIy0>rRrXj%bO2%=%iXd=^1onuqMvhGJwC|=L?-4UzOF!Y zsfel|D2P0!(ro%Jy5Ng-9NHzvia%{C&4@;(Mkd1i(+%S8cv0MKJEQA^pk{kA7yX!S zQc>ndtxPLq8sR|38=xUF8b-!}@=t6M??sfsh`EnaKKrzat=>gfX%AUAztnx&5obne zN;QAe=9WETp-}rX-d4?uyXuMzK;D+;#oK?dt07TcB-dDyz7g?-3r@cRyxYb$6m_s# zU&TG;`4I-7(`Upz@G5qnvY!L3TEF;5FsU%0>9=>OgS`l6#`OBCHMQr8)Q70`HP|S= zN~!8AWsz_ocP|z>JPD2KDP*zgY9;lyo0)aT*^A(}G5_@Yrx_Wwi#>C?IP>RX+?!z1 z^k1I)jok<%aMsv42^CZ21gg}mBzzy+FG$|nYuSl zV2Bo%vU7M$o_G?6IJm?B1q-i5E5+n$7N@u~I~X=Lb1+wS?jWwQBLqO+;bY==bQqFq zXC@ZRmCT)Q2N?xGsFz^=k6R6Z5|=KLG~lwQs-{~A?LT}N{b>)K_c{QCU$mB)xg7uj z*!=Uy#XpK+=XI6Ed|BNTv1hzRtSQ$6Bpl{L=Px!iwK~s=*G~BZ2q1eZ0IHiVUIR{T z&3geU0P;F{ry+Q18_zef4^WmR~GnLLtqqrEa!9WSl=EbFKJcfGs~{JyHzudCX!!Qaj3&R z_xkGb$b_jy83vS;>35vMk9!5?hS_4ZpaO&@bFGuKC_*Fv60?FL08mXvoH?@#&#uH( zKOExpoLqZ79lfFeo*+#*MgElol6?2u47e01CeX?1WZ&_VpNaeIR!OY9QZgUBQ><~L znVO(O6cFcH#oKp~{wvh2Ww~u@q;sQo%veb*VYSADd2|rkF)KT~#)H!SKi?Ja*a@lq z`#yYf=2Y4XP(6Uv_yU{&@T+xL0h7W#RXcso?PAr>Er3J8 z)!sl}L}w#hB-tl6D6d@7-nRQ#aK>R4DnFco@_^p`eb;Sg0xh%W_lUFSld)^%0Y<>GWm}aP9 zGV`>V{>a?%4soV%pN#G;2A9eyrPK1XspUle-^ z6KDk0NAg?#mX2%)O6H_oXzZnuT=8Z7p}A-;+)d+WBKA%_i`(vhqCats@3S)zE}X1q zs^n>Lmi#q#f?3dlwwmb*Ppru|!1Z*lg_oiAARG+00S&s0NS*T|2$29t+B$gX0)VV^ znvN@Uw7%)iBHnD}N~=~#a^*5a6{3O9lzNQZoSD;j3y(tFOC+~(lXU9pie}zmb%@4fd zvcGv;(zo3r$s4YbJv8S$qJF%o_>=Ka~w;1r4+f;NNe~NNzSat+3Rea&h?;U=W7pQ^f{wtHs zN4N6N?gHqoh1%e1795JERXs!lAblCbpa7bo0D?oHZzzEH-nC@j_b#z#O*ftDo@J_k6K_vpS57F*Z|IglbN8R3Q-mYYZRTpdaoSo$@18q6m0fApE1eq z?(KA_%?JB@S5vd$eM}pcW9UW$W`-o&8`3W6(R_gxg-wRc;)W>h$ z1F}j+a;!@APcoWJQT~`ks5Dp~bq$zuAPt6d{>cRHzVJ2iPCO;a>+YkzTAqD3H2Jl7 zdmnhx8ampg+^dQ>6{r{r=k}b@N(L5GgnmgudFEl$+o}+xl;tBy-nn$;^iG`3^*tinJ}~ zb%S!H%sbx7Jq`|KIKDmHM4gPnCb?2SrwP$H6P;=f%K8;__I415LQAj|U^8z6JWBys z7Vh^sbglh7^Bu&$op&J*$HO@+1DK6seUvlzI?zTrlLn0sfr5F!ny{h}tqT#0xt)SA z!F>^gNB|_0Co!@>c^*5uVBb*%7qR2&5*{7i!HujthVTQNim!T zaQSDq(J8HB&z>cT%P%b`%aoIO?>p)2gW|ES%wrwA%$mVljR?>~H&;+*p$dD=81dTZ zB*dpxlQMdci!P+1P7RCB%o&At5~qN*tVR@fmQw4?{b)QXJLzxDJ?GCG@4o=!n@Ho~RmQigIMK%s%WAvny)ZPIG70FgBn zC0<8+1m#v1@uDL#K&hNfRN0OoL;@fcS$T98s}B8QEP4TYhmR;yj^neS(5z8c0KI?? z(9mdfA(ICs*9)gV;3DaF-)2DLT(*ce8#Y2Y9>7Y!>sB-3l6(&Fh0R3uBg*w#7^yIV zG6Nz2nfcKBO?e)MM2CE;A0@A4G&Tv4sqJUNcz}3-PG|kP*`%R~_O^*bRaQ!e)f>SN zq7)mDH3pHZ8z$iFB~G&NOd%)$jJ@E4g>+rAtcwKI=96#cW#Jwbe;tKkxX;pN(fioR z!@_~y!Eop+hok(0krJ>mbzx7r5&EGclE-T~h)4xY5rjwpWT70Z4MmNGssrh$#yfJv zJigz^$V!LMdGsAbkq((vvDQ59p~I4A2H$77vx>OP9n3&u*i+!>(A#TkGCEOu`BD+A z&4mIXI0ixY&=JGY1Q%MZLpfafmi5y5&F@O~@h1^)tLUuBMjumU-E=A-a#=U8qedUI zgM0WmzX36~iwz*Rqwp@j!i=gc-M*y;-9>|rj#!B3Ve64`SEBdS0`T_*qcp^mT94sW zezv8cYUz|myK*zC#mmq;wHd|*mr^?ryLMRha{wlsSMO7?2KTaR-wTI>vmy$ym{B*1 zegq*B067aV*0DQ=pkH_gUz1KaOMcf&=J9izw;7J18!D(yVo#hP-s~CDxnZMO-rV`) z|CPkz1?V+^3Vp`Y5t`a%l5iQgME->r4WN{ZS#0T|X=Zr-)E_0!5Zzh8C=cVKawQd0 z?&hZ@w|_78m>bY%uqsdHO@=!ufQ-TLwO0{obXq#e=xeLwcLS(SL|y_tXg;9c`Bl^D zwu+))sf~{<$iq*^Z`cagfs>5Vw!)e4A{4lZQKH)p0|>0qCovJAH~CF3cXhwJ_aOk& z%6+^9HZL02K0AO|a3vaM>hl|(6ANzVpWO}D28^EXk}zr-6a=D==_VNBn-ff%L1A3Not*ocspymVCZ~%mbRmtW1rB1L10G~zSGzb`l|DN>P^uD$gi7=wR?F#T0XE@qXQ7zsMs&6jQF}XvSvgEAd5W<9gKzE%BAQb&qqc8 zt=OK4LVYWX3Ngu48Y~>jq7G(ts*cVga3l+#Ju(34D9B-+$!EhTcPpcn2)U5^3S#Vs zkR%h5rTkfSC1*Z>dvp0bRY`&S_)}@bxMfwTu3RP20f-ine-6l@?q|0c31RWpfRFfM*(LSTXdA*vOn2q*TbT6h zh_~==raLfbsw-;(dg%o2=u7^Tr;1n61Bi#!iB;#SD<%CmpOVfm-D~`gySYbHi6}ZU zwy@1*?quel*#r|5P%65R7?J8eG{9f{*MW)~qu1%rd_q!iA`9IpvcQCWejig_PvGpfm{lWipGu94gVJVvef&kA6o28z z2c`H6DRk&?prWTpeGsQBmhE)j_Z{)}9-uc%Tt$oW4$7rEyDgl{Hoy1aN&Rkp`wM=_b&=cD7l)3wp~uOBxbRO$1c0mxuB8oyOH z@I*W>s0ERoQO!yTb)TpwkM7IE$5yP@`>~btD&U7MLR8E07(5*H^H1svWY&O{W*l0- zhpq@KrIUh1o$UXBV;JkUd~_pEy%){rp4u5UVGHRan1@Bbi9h$$2carUz6eBEFAw6q z{0bW|Y?Hn}I0tOhxYPB)Ik=gAaE@g`iJNX1m=DgKdYV0YcJdjhmyMU9St(lPgLB48 zsXo){KV3H)u7g#1+zL3(!d{+sNG4s}06LGPtSK{}ykyr8UHYtB2m9@;y3mambnQIu zv*weRxW}61$-PXLo>y+af50VmRYv&9OMISnO}iPr=_fArlb5=BLRVK@{GYrWMgZz} zN5?k|00j(yhMXaB03IwZqKXuV0aQg|567xv01d}pt>QDq0IDLfhhtSSfQDnQR`Ho) z09BFL!?CIuK*O|-Kl6f24u zeRAU_tQT!AxsFn(r9tm(u~h8?l3bsXqYohtoVkN_h$%J$o!r4`G@Ij~L+u|37( z5&93eEyu`OSO@vKvmn2mE77+op`n_!Z^_n7{tF@o^px>&*>7@qX!7gSUFiE1h1cMB zk2&V{&*}h25{lLf0j6S}B(9E|OoZDuu)q<<_fZD9V5cLbz=Mh?L5-XUu--xL6)=9N z(any#(C%KrEy6|oxc!WXRUp1uP7ap5L*mAEbc(&?l@#~7b_dr{gEyxm9BhlOx)a^k4` zCrat@`#qg0PXiu1l`qjE9*dv%dhQdA-^KZlEfe27;#2tA94#T<*0r~`nF$;O#tWL*@->-!r(ba3mK@E##V?8u=Oj**? zH)!gB$E}v&UKaV%(RdEJmQPD(8@EZ{bSU0iSme>PNq7iaMo%73z11}qYR$y|ma&lSTZ@707ThJ$x_Z5V)NT=&h zTE*>dQhS7k#q}(+<#MFo^Ms@^d7i73I(Ug(7K4h%k6q`@7}W=Y)P|3GfyEfEc$E2l zsqE+%*>wkY^^vr;q34`C7C5PwA6g$J;*>E_!GHH*Yj>wh1_2yD3KJ;U5I6kS` z6qMPzRmD^Uyq8FdzI^OOSDtV75pfDj{Fgx|6VXsrE=KDYgB137g?_JE1yEtc1bvZQ zEQU@-ekWr0Dvz!I~VXZJI9&7h5un9+a9?aMKTI>Zz;~XX7 ztMfHkGZA94l4MUNL?G0QVkAd#V!;!@KQ|$PZx^;9C5{me!`^Qur6w;h#6%Cd6WgX= z=xKBpb>o`sPk(M+ll0y@$xE_W(YG01K9ERcW>`mPn2ze zNV}eh$6uCe-j6VL<6oyC3Bwl*h3HH1@w&*D5 zvmQhy+jQe99)$54XB+pX_ENu*b#f@PIPIbF%PqKFk4}!W1ZLe&9|1TxUp+1sLrZOH z^{8HOv=Qvyjj-)VFeL>dUq8^Hl0(}>6pO6l7AulCKn;3dwg`v)lWe4N=#TP(&n{2J zTMyJA>^zo^LwLAyZibp#H-rpJbC;Ic%mPutLjI3^m!&2$kPQrzxrJ)s6Z1fExrh`6 zGX;rT+lYe-xh}y^112;~80sC(pnxFt$A=iAm=WnhVY;rZZhxX^823A{;N?NC=oNEz zbWOx8nbr148|46I>4$MG4vt}zD8h*EBeiz?<;X|J;hMTsNcIbAPr@vK+5{bxc#T55 z7pbX-5v@z3}gUSz9)YIEE)KAY}r5)(QVH zuM*ksClC)C0i4Xp&O9A&J=LqK+N5=-#>s14u{@PH7yCkS9vywJ=463jLE=;*16`cl zeDGaO2HhU)y@Eg~iFm=-M7{?1;uod?qafGo>C$g`Yjt8v-sdk}-vLx&uHPgMZg{cU zhgEHIk`kL|?RJW+s>q9FUFyS4Nz_-H9v173W$ia+#Bs(B!~ZEbku+S%;C>U+Thc7y{Ii&k1y1xGp+XhpuMG5mTh&Ju8%u9(3LD!*P^5pH>MbR4 z{phtmesyqp=x_q3j}*z-^|z78i(SL)-!2MP`9os!Sc#}5L&$(-`O6QYCYOd$p+^;= z9Rd6I_Wk@iHi9!n{S7{4NgUS`i$Z$&mmKBhGG17@wm6w7^6p|1-{d$jX{5xs!eX-Y zvwl-~eNe8pvrAsTOTJL_iX=*YgL_z9Y zNVOMS+;n&d0_5@pUQsxs7Xf#*C|Jh?seggn@6tKDah(MgoHNGWq4XYGBIBv`tR#Ug zqpFWLSiF(vPH+2`!ucY6lYHP=peR#XZ#h2tbqr)3?O&lNM~&w=I~5kdzfIhWnB6j` zf)e7lXFvS4r*f!xscwN)84Ux*q82pEdkPl4)dj4QZ4 zv<+PlRgZS|Gbb*5vSK-YCySR08;D2X%SI4}5HI_!8@9-LW=Ra=w5(&1au$DKo3)@w zU)@isvPk)Ckd(2YRf4{70eYqW9t;C_hW_!_Wi;saRQCe&*~mopzjX4Cf`@guowFEo zvQBDms6?PNx$bnQ9!rKwLJxv5GLyN<&9)r>Ooc5`CeiDeM`*LlkHmD{WA%^e1Upv? zAaS12Pkgf!k^h}2cD!cXzV)25qa1EPH#|y%4=D+jZ>nRao!5o@NxvSVQR5)@d?OL> z5crx9YJi_>b)esCLA$G10N(6e=@X9v#xzP_`EK?nP${&pS4+nlk`vGz*~EZO%MpV)0QpQqKrWMlzuc~sv1LVH27Izm&?z=oE({lL7m@D z9C9q{3i2O7$cuhn#(<)~u}PGD-h{#sfmmr0!Hu4Xf&%mxQpHLxwh#SBoT~L zx!0Xl`zR(QhErUcvXHqz_^_3uI-uvfWmDy1a|MCsS{mH@W>BO>2=`crC^sH4f3}h8 zb%yt#DtZ1~JszO35#p28_uQY#TrvC(!TR`Ok#N zrSb-ibeq}`(WvGx_!KMc&LB6K$<(6Myp2^GI@mdR*e0_=>nc>l8{*XMsQZNoBy2%R zl;K*rrzHLnRw@y-V0*`W{otV636Ft9G{iI9ZC(iqQuny1;>BSW&1;(Y?AE%FKyi5L zxRo6$MN&zc40_y0=xx=Hb+jeIg@q#F;v^-no1)aF?Y_nLq#?9y`@I6qoOUajf1*4pnrQ^rc9MW&8SJqfOsXapA)K;_(V57BR3rNX4ycd;$nS;X-_755eL-30~7o!qS{W zIwi3{YSqFh{i9zcpN)LO6QiT*s@Sk_%8fQIk!zN4b>#3Farws)E13Fg;$U!zf$ zqZqQ+$nAZd;#}F$DrCm&#b9MDxGcn9M*ZyctVQ>4@6kOpbq9o@+0nHw#JJq~=yfyW ztx+GfqaKQ5=+tZ|$+dZn|Krma9&cg#>Hz@R#N11uozgOd0k*^ z?|EofsWYvTErwc2@#bulJJl-RepbK~Ti;zaUN3|3sK1+Tx0S`L6%)f9i54PQ^s_^D zWbB$a+eBdUR@M5<(LvwM*p*tH-BC$TmL9d1iO)sBjKVYBes!HnZ59VWVK>|RC~OG7 zOAUv?L^qqAiRoOq9EM1INi^f1ul2srm@*Y~?{XZWfqg_b?~as{54L1cW8-W9d?H;5 zS;J|XM_iqvyn=F#{%oRqe9zzf?u45jKS)WA+r)IX3gI*?H5bwcBKO~&HsDJNW}n3_ zZ$P!~KsqDdj56XlTpYI?<<2bqc*^WN{n~$T1-|O?AX|JEZf0Y|jS8dM-pH+&N+fPW zEgR0?P5KEjkeCc#i85UM>YF}&t6Nap9-%xnBf*~^tp{ZaQ&;M@0 zQD`$~MK@km9b3m*dbx$ug zkkP1a2T(^Y&+|20K~Q%&hLC0y3^@ap;(x$JgU> z3ENh`I;EMkSzbRqY^qDSesUrhZBr|MoWPPmnc$-C(6ByMfVGZ_`dE4vxmXxqpYAr$ z$<}1O$)<@Sg37Eu+EOW!-0&JxH|awsFYjg>e(Jn$Yi>p&T(s!S7t=8iV{hRX_d#x> zuOY3rOfIX2M#g&V8hkGk1STh@S8FcoaLeG-G~rAure1DCfzdR5+^0$#AZU&ByIsZ9 zG-KD+;Q*&*b0iYKv1#gu%Zc~Z=~I>Z|9MnWR@KtOV@Be`2JF8As{hZg*qz^pvJUsV zD4r9BX~4&484@rQ=riwYvUtve50xzj_EzQ`T0hP_T^nAGfp!=Kufz literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/Maestro.png b/node_server/WebApp/icons/Maestro.png new file mode 100644 index 0000000000000000000000000000000000000000..e8e578065802bf83678520866f2339c191be8b88 GIT binary patch literal 6712 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 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/RBS.png b/node_server/WebApp/icons/RBS.png new file mode 100644 index 0000000000000000000000000000000000000000..2f87ecea3d0a76de5986b3234b3ab44a0aed038a GIT binary patch literal 11814 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 YUp+^fxK6hIy}kh|ig1NWIg8N$1HN2-0{{R3 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/VISA_CREDIT.png b/node_server/WebApp/icons/VISA_CREDIT.png new file mode 100644 index 0000000000000000000000000000000000000000..3073a0ebf57599e8b6b6a070cfa21da54d5def44 GIT binary patch literal 4519 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 YUp+^fxK6hIy}kh|ig1NWIg8N$1HN2-0{{R3 literal 0 HcmV?d00001 diff --git a/node_server/WebApp/icons/bridge-card.png b/node_server/WebApp/icons/bridge-card.png new file mode 100644 index 0000000000000000000000000000000000000000..32af36b629c3d00e0537a4b22f729e80276ba638 GIT binary patch literal 39626 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 WttHhuU_t=vW<79Aa?{ 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 P00000NkvXXu0mjf)cXb0 literal 0 HcmV?d00001 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