From fbdbdd8edfd953bc1928a08132ae34bde5409632 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Fri, 29 May 2020 15:12:46 -0500 Subject: [PATCH 01/29] Updated library to adapt dfsp party accounts. --- docs/dfspInboundApi.yaml | 27 +++++++++++- src/InboundServer/api.yaml | 8 +++- src/OutboundServer/api.yaml | 41 +++++++++++++++---- src/lib/model/lib/shared/index.js | 6 ++- .../api/transfers/data/putPartiesBody.json | 12 +++++- src/test/unit/data/putPartiesBody.json | 12 +++++- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/docs/dfspInboundApi.yaml b/docs/dfspInboundApi.yaml index 53d0d0ae5..ac840b6b9 100644 --- a/docs/dfspInboundApi.yaml +++ b/docs/dfspInboundApi.yaml @@ -380,6 +380,13 @@ components: minItems: 0 maxItems: 16 + accountList: + type: array + items: + $ref: '#/components/schemas/account' + minItems: 1 + maxItems: 32 + geoCode: type: object description: Indicates the geographic location from where the transaction was initiated. @@ -478,6 +485,23 @@ components: minLength: 1 maxLength: 128 + + account: + type: object + properties: + address: + type: string + minLength: 1 + maxLength: 1023 + currency: + type: string + minLength: 3 + maxLength: 3 + description: + type: string + minLength: 1 + maxLength: 128 + initiatorType: type: string enum: @@ -680,6 +704,8 @@ components: description: Up to 4 digits specifying the sender's merchant classification, if known and applicable. extensionList: $ref: '#/components/schemas/extensionList' + accounts: + $ref: '#/components/schemas/accountList' transferState: type: string @@ -833,4 +859,3 @@ components: minLength: 1 maxLength: 128 description: Either a sub-identifier of a `{idValue}`, or a sub-type of the `{idType}`, normally a `{personalIdType}` - diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index e329b3fd9..11af0e59b 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -686,6 +686,13 @@ paths: middleName: Pierre lastName: Trudeau dateOfBirth: '1971-12-25' + accounts: + - currency: USD + description: savings + address: moja.red.8f027046-b82a-4fa9-838b-70210fcf8136 + - currency: USD + description: checkings + address: moja.red.8f027046-b82a-4fa9-838b-70210fcf8137 responses: '200': $ref: '#/components/responses/Response200' @@ -4424,4 +4431,3 @@ components: description: 'Optional extension, specific to deployment.' required: - transferState - diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index ee7bd1c6f..c37d37252 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -134,8 +134,8 @@ paths: The underlying API has two stages: 1. Party lookup. This facilitates a check by the sending party that the destination party is correct before proceeding with a money movement. - 2. Transaction Request. This request enables a Payee to request Payer to send electronic funds to the Payee. - + 2. Transaction Request. This request enables a Payee to request Payer to send electronic funds to the Payee. + tags: - RequestToPay requestBody: @@ -233,7 +233,7 @@ paths: $ref: '#/components/responses/transferServerError' 504: $ref: '#/components/responses/transferTimeout' - + /accounts: post: summary: Create accounts on the Account Lookup Service @@ -616,7 +616,7 @@ components: enum: - true - false - + transferContinuationAcceptOTP: type: object required: @@ -670,6 +670,8 @@ components: extensionList: $ref: '#/components/schemas/extensionList' + accounts: + $ref: '#/components/schemas/accountList' extensionItem: type: object properties: @@ -703,6 +705,29 @@ components: required: - extension + accountList: + type: array + items: + $ref: '#/components/schemas/account' + minItems: 1 + maxItems: 32 + + account: + type: object + properties: + address: + type: string + minLength: 1 + maxLength: 1023 + currency: + type: string + minLength: 3 + maxLength: 3 + description: + type: string + minLength: 1 + maxLength: 128 + transferRequest: type: object required: @@ -802,9 +827,9 @@ components: $ref: '#/components/schemas/initiatorType' authenticationType: type: string - description: OTP or QR Code, otherwise empty + description: OTP or QR Code, otherwise empty requestToPayState: - $ref: '#/components/schemas/mojaloopTransactionRequestState' + $ref: '#/components/schemas/mojaloopTransactionRequestState' requestToPayTransferRequest: type: object @@ -1281,7 +1306,7 @@ components: content: application/json: schema: - $ref: '#/components/schemas/errorAccountsResponse' + $ref: '#/components/schemas/errorAccountsResponse' requestToPaySuccess: description: Request to Pay completed successfully content: @@ -1301,7 +1326,7 @@ components: schema: $ref: '#/components/schemas/errorTransferResponse' - + parameters: transferId: diff --git a/src/lib/model/lib/shared/index.js b/src/lib/model/lib/shared/index.js index baf80f88d..93ab1ca28 100644 --- a/src/lib/model/lib/shared/index.js +++ b/src/lib/model/lib/shared/index.js @@ -25,7 +25,7 @@ const internalPartyToMojaloopParty = (internal, fspId) => { fspId: fspId } }; - + if (internal.extensionList) { party.partyIdInfo.extensionList = { extension: internal.extensionList @@ -53,6 +53,8 @@ const internalPartyToMojaloopParty = (internal, fspId) => { party.merchantClassificationCode = internal.merchantClassificationCode; } + if(internal.accounts) { party.accounts = internal.accounts; } + return party; }; @@ -96,6 +98,8 @@ const mojaloopPartyToInternalParty = (external) => { } } + if(external.accounts) { internal.accounts = external.accounts; } + return internal; }; diff --git a/src/test/unit/api/transfers/data/putPartiesBody.json b/src/test/unit/api/transfers/data/putPartiesBody.json index e46c03950..eae0c71f3 100644 --- a/src/test/unit/api/transfers/data/putPartiesBody.json +++ b/src/test/unit/api/transfers/data/putPartiesBody.json @@ -15,6 +15,16 @@ "dateOfBirth": "1980-01-01" }, "name": "John Doe", - "merchantClassificationCode": "1234" + "merchantClassificationCode": "1234", + "accounts": [ + { "currency": "USD", + "description": "savings", + "address": "moja.red.8f027046-b82a-4fa9-838b-70210fcf8136" + }, + { "currency": "USD", + "description": "checkings", + "address": "moja.red.8f027046-b82a-4fa9-838b-70210fcf8137" + } + ] } } diff --git a/src/test/unit/data/putPartiesBody.json b/src/test/unit/data/putPartiesBody.json index e46c03950..76e356486 100644 --- a/src/test/unit/data/putPartiesBody.json +++ b/src/test/unit/data/putPartiesBody.json @@ -15,6 +15,16 @@ "dateOfBirth": "1980-01-01" }, "name": "John Doe", - "merchantClassificationCode": "1234" + "merchantClassificationCode": "1234", + "accounts": [ + { "currency": "USD", + "description": "savings", + "address": "moja.blue.8f027046-b82a-4fa9-838b-70210fcf8136" + }, + { "currency": "USD", + "description": "checkings", + "address": "moja.blue.8f027046-b82a-4fa9-838b-70210fcf8137" + } + ] } } From 7bd46f6a70e4d7b31f88db103844d0c829d5c479 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Sat, 30 May 2020 15:30:32 -0500 Subject: [PATCH 02/29] Updated adapter. --- src/lib/model/lib/shared/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/model/lib/shared/index.js b/src/lib/model/lib/shared/index.js index 93ab1ca28..af0424185 100644 --- a/src/lib/model/lib/shared/index.js +++ b/src/lib/model/lib/shared/index.js @@ -53,7 +53,11 @@ const internalPartyToMojaloopParty = (internal, fspId) => { party.merchantClassificationCode = internal.merchantClassificationCode; } - if(internal.accounts) { party.accounts = internal.accounts; } + if (internal.accounts) { + party.accounts = { + account: internal.accounts + }; + } return party; }; @@ -99,6 +103,9 @@ const mojaloopPartyToInternalParty = (external) => { } if(external.accounts) { internal.accounts = external.accounts; } + if(external.accounts){ + internal.accounts = external.accounts.account; + } return internal; }; From 6be7d666c6316da740bae828348c6fa5e28144a2 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Mon, 1 Jun 2020 06:15:54 -0500 Subject: [PATCH 03/29] Fixed test data. --- .../api/transfers/data/putPartiesBody.json | 26 +++++++++++-------- src/test/unit/data/putPartiesBody.json | 26 +++++++++++-------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/test/unit/api/transfers/data/putPartiesBody.json b/src/test/unit/api/transfers/data/putPartiesBody.json index eae0c71f3..67faec305 100644 --- a/src/test/unit/api/transfers/data/putPartiesBody.json +++ b/src/test/unit/api/transfers/data/putPartiesBody.json @@ -1,5 +1,19 @@ { "party": { + "accounts": { + "account": [ + { + "”currency": "USD", + "”description": "savings", + "address": "moja.red.8f027046-b82a5456-4fa9-838b-70210fcf8136" + }, + { + "currency": "USD", + "description": "checkings", + "address": "moja.red.8f027046-b8236345a-4fa9-838b-70210fcf8137" + } + ] + }, "partyIdInfo": { "partyIdType": "PERSONAL_ID", "partyIdentifier": "987654321", @@ -15,16 +29,6 @@ "dateOfBirth": "1980-01-01" }, "name": "John Doe", - "merchantClassificationCode": "1234", - "accounts": [ - { "currency": "USD", - "description": "savings", - "address": "moja.red.8f027046-b82a-4fa9-838b-70210fcf8136" - }, - { "currency": "USD", - "description": "checkings", - "address": "moja.red.8f027046-b82a-4fa9-838b-70210fcf8137" - } - ] + "merchantClassificationCode": "1234" } } diff --git a/src/test/unit/data/putPartiesBody.json b/src/test/unit/data/putPartiesBody.json index 76e356486..67faec305 100644 --- a/src/test/unit/data/putPartiesBody.json +++ b/src/test/unit/data/putPartiesBody.json @@ -1,5 +1,19 @@ { "party": { + "accounts": { + "account": [ + { + "”currency": "USD", + "”description": "savings", + "address": "moja.red.8f027046-b82a5456-4fa9-838b-70210fcf8136" + }, + { + "currency": "USD", + "description": "checkings", + "address": "moja.red.8f027046-b8236345a-4fa9-838b-70210fcf8137" + } + ] + }, "partyIdInfo": { "partyIdType": "PERSONAL_ID", "partyIdentifier": "987654321", @@ -15,16 +29,6 @@ "dateOfBirth": "1980-01-01" }, "name": "John Doe", - "merchantClassificationCode": "1234", - "accounts": [ - { "currency": "USD", - "description": "savings", - "address": "moja.blue.8f027046-b82a-4fa9-838b-70210fcf8136" - }, - { "currency": "USD", - "description": "checkings", - "address": "moja.blue.8f027046-b82a-4fa9-838b-70210fcf8137" - } - ] + "merchantClassificationCode": "1234" } } From ed5bc9d0161973912597e2e6f050478f3beb0091 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Mon, 1 Jun 2020 06:22:05 -0500 Subject: [PATCH 04/29] Fixed data structure. --- src/lib/model/lib/shared/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/model/lib/shared/index.js b/src/lib/model/lib/shared/index.js index af0424185..d03d8314b 100644 --- a/src/lib/model/lib/shared/index.js +++ b/src/lib/model/lib/shared/index.js @@ -102,7 +102,6 @@ const mojaloopPartyToInternalParty = (external) => { } } - if(external.accounts) { internal.accounts = external.accounts; } if(external.accounts){ internal.accounts = external.accounts.account; } From d66b26fd7c002a6eaff97be369bddcf9faf5c872 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Mon, 1 Jun 2020 18:44:55 -0500 Subject: [PATCH 05/29] Edited CI to build PISP docker image. --- .circleci/config.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 155533a31..cdcb1bf24 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -98,8 +98,17 @@ defaults_build_docker_publish: &defaults_build_docker_publish defaults_build_docker_publish_release: &defaults_build_docker_publish_release name: Publish Docker image $RELEASE_TAG tag to Docker Hub command: | - echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" - docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG + case "$CIRCLE_TAG" in + *-pisp*) + # Don't update `late5t` for an image that has a `-pisp` + echo 'skipping late5t tag' + exit 0 + ;; + *) + echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" + docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG + ;; + esac defaults_slack_announcement: &defaults_slack_announcement name: Slack announcement for tag releases @@ -649,7 +658,7 @@ workflows: # - audit-licenses filters: tags: - only: /v[0-9]+(\.[0-9]+)*\-snapshot/ + only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)/ branches: ignore: - /.*/ @@ -677,7 +686,7 @@ workflows: # - audit-licenses filters: tags: - only: /v[0-9]+(\.[0-9]+)*/ + only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)/ branches: ignore: - /.*/ @@ -695,7 +704,7 @@ workflows: # - audit-licenses filters: tags: - only: /v[0-9]+(\.[0-9]+)*\-hotfix(\.[0-9]+)/ + only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)/ branches: ignore: - /.*/ @@ -709,4 +718,3 @@ workflows: # branches: # ignore: # - /.*/ - From e785edad85dd44d5a4ae79661d24eebe53870fa4 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Wed, 10 Jun 2020 05:43:06 -0500 Subject: [PATCH 06/29] Updated CI. (#167) --- .circleci/config.yml | 737 ++++++++++++------------------------------- Dockerfile | 4 +- 2 files changed, 201 insertions(+), 540 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cdcb1bf24..402f1a924 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,35 +1,40 @@ -# CircleCI v2 Config -version: 2 - -defaults_working_directory: &defaults_working_directory - working_directory: /home/circleci/project - -defaults_docker_node: &defaults_docker_node - docker: - - image: node:lts-alpine - -defaults_docker_helm_kube: &defaults_docker_helm_kube - docker: - - image: hypnoglow/kubernetes-helm +# CircleCI v2.1 Config +version: 2.1 +## +# orbs +# +# Orbs used in this pipeline +### +orbs: + anchore: anchore/anchore-engine@1.6.0 + deploy-kube: mojaloop/deployment@0.1.6 + slack: circleci/slack@3.4.2 + +## +# defaults +# +# YAML defaults templates, in alphabetical order +## defaults_Dependencies: &defaults_Dependencies | - apk --no-cache add git - apk --no-cache add ca-certificates - apk --no-cache add curl - apk --no-cache add openssh-client - apk add --no-cache -t build-dependencies make gcc g++ python libtool autoconf automake - npm config set unsafe-perm true - npm install -g node-gyp + apk --no-cache add git + apk --no-cache add ca-certificates + apk --no-cache add curl + apk --no-cache add openssh-client + apk --no-cache add bash + apk add --no-cache -t build-dependencies make gcc g++ python libtool autoconf automake + npm config set unsafe-perm true + npm install -g node-gyp defaults_awsCliDependencies: &defaults_awsCliDependencies | + apk upgrade --no-cache apk --no-cache add \ - python \ - py-pip \ + python3 \ + py3-pip \ groff \ less \ mailcap - pip install --upgrade awscli==1.14.5 s3cmd==2.0.1 python-magic - apk -v --purge del py-pip + pip3 install --upgrade pip awscli==1.14.5 s3cmd==2.0.1 python-magic defaults_license_scanner: &defaults_license_scanner name: Install and set up license-scanner @@ -37,107 +42,37 @@ defaults_license_scanner: &defaults_license_scanner git clone https://github.com/mojaloop/license-scanner /tmp/license-scanner cd /tmp/license-scanner && make build default-files set-up -defaults_npm_auth: &defaults_npm_auth - name: Update NPM registry auth token - command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > src/.npmrc - -defaults_npm_publish_version: &defaults_npm_publish - name: Update version to prerelease - command: | - source $BASH_ENV - echo "Publishing tag $CIRCLE_TAG" - cd src && npm publish --tag $CIRCLE_TAG --access public - -defaults_npm_publish_release: &defaults_npm_publish_release - name: Publish NPM $RELEASE_TAG artifact - command: | - source $BASH_ENV - echo "Publishing tag $RELEASE_TAG" - cd src && npm publish --tag $RELEASE_TAG --access public - -defaults_Environment: &defaults_environment - name: Set default environment - command: | - echo "Nothing to do here right now...move along!" - -defaults_build_docker_login: &defaults_build_docker_login - name: Login to Docker Hub - command: | - docker login -u $DOCKER_USER -p $DOCKER_PASS - -defaults_build_docker_build: &defaults_build_docker_build - name: Build Docker $CIRCLE_TAG image - command: > - echo "Building Docker image: $CIRCLE_TAG" - - docker build - --build-arg=BUILD_DATE="$(date -u --iso-8601=seconds)" - --build-arg=VERSION="$CIRCLE_TAG" - --build-arg=VCS_URL="$CIRCLE_REPOSITORY_URL" - --build-arg=VCS_REF="$CIRCLE_SHA1" - -t $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG . - -defaults_build_docker_build_release: &defaults_build_docker_build_release - name: Build Docker $RELEASE_TAG image - command: > - echo "Building Docker image: $RELEASE_TAG" - - docker build - --build-arg=BUILD_DATE="$(date -u --iso-8601=seconds)" - --build-arg=VERSION="$RELEASE_TAG" - --build-arg=VCS_URL="$CIRCLE_REPOSITORY_URL" - --build-arg=VCS_REF="$CIRCLE_SHA1" - -t $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG . - -defaults_build_docker_publish: &defaults_build_docker_publish - name: Publish Docker image $CIRCLE_TAG & Latest tag to Docker Hub - command: | - echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG" - docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG - -defaults_build_docker_publish_release: &defaults_build_docker_publish_release - name: Publish Docker image $RELEASE_TAG tag to Docker Hub - command: | - case "$CIRCLE_TAG" in - *-pisp*) - # Don't update `late5t` for an image that has a `-pisp` - echo 'skipping late5t tag' - exit 0 - ;; - *) - echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" - docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG - ;; - esac - -defaults_slack_announcement: &defaults_slack_announcement - name: Slack announcement for tag releases - command: | - curl -X POST \ - $SLACK_WEBHOOK_ANNOUNCEMENT \ - -H 'Content-type: application/json' \ - -H 'cache-control: no-cache' \ - -d "{ - \"text\": \"*${CIRCLE_PROJECT_REPONAME}* - Release \`${CIRCLE_TAG}\`: https://github.com/mojaloop/${CIRCLE_PROJECT_REPONAME}/releases/tag/${CIRCLE_TAG}\" - }" - +## +# Executors +# +# CircleCI Executors +## +executors: + default-docker: + working_directory: /home/circleci/project + docker: + - image: node:12.16.1-alpine + + default-machine: + machine: + image: ubuntu-1604:201903-01 + +## +# Jobs +# +# A map of CircleCI jobs +## jobs: setup: - <<: *defaults_working_directory - <<: *defaults_docker_node + executor: default-docker steps: + - checkout - run: name: Install general dependencies command: *defaults_Dependencies - - checkout - - run: - <<: *defaults_environment - run: name: Access npm folder as root command: cd $(npm root -g)/npm -# - run: -# name: Install interledgerjs/five-bells-ledger-api-tests -# command: npm install github:interledgerjs/five-bells-ledger-api-tests - run: name: Update NPM install command: cd src && npm install @@ -150,15 +85,12 @@ jobs: - src/node_modules test-unit: - <<: *defaults_working_directory - <<: *defaults_docker_node + executor: default-docker steps: + - checkout - run: name: Install general dependencies command: *defaults_Dependencies - - checkout - - run: - <<: *defaults_environment - restore_cache: keys: - dependency-cache-5-{{ checksum "src/package.json" }} @@ -171,8 +103,7 @@ jobs: path: /home/circleci/project/src/junit.xml test-integration: - machine: true - <<: *defaults_working_directory + executor: default-machine steps: - checkout - run: @@ -196,15 +127,12 @@ jobs: path: /home/circleci/project/junit.xml lint: - <<: *defaults_working_directory - <<: *defaults_docker_node + executor: default-docker steps: + - checkout - run: name: Install general dependencies command: *defaults_Dependencies - - checkout - - run: - <<: *defaults_environment - restore_cache: keys: - dependency-cache-5-{{ checksum "src/package.json" }} @@ -217,330 +145,137 @@ jobs: - store_artifacts: path: /lintresults - - -# test-coverage: -# <<: *defaults_working_directory -# <<: *defaults_docker_node -# steps: -# - run: -# name: Install general dependencies -# command: *defaults_Dependencies -# - checkout -# - run: -# <<: *defaults_environment -# - run: -# name: Install AWS CLI dependencies -# command: *defaults_awsCliDependencies -# - restore_cache: -# keys: -# - dependency-cache-{{ checksum "package.json" }} -# - run: -# name: Execute code coverage check -# command: npm -s run test:coverage-check -# - store_artifacts: -# path: coverage -# prefix: test -# - store_test_results: -# path: coverage -# - run: -# name: Copy code coverage to SonarQube -# command: | -# if [ "${CIRCLE_BRANCH}" == "master" ]; -# then -# echo "Sending lcov.info to SonarQube..." -# aws s3 cp coverage/lcov.info $AWS_S3_DIR_SONARQUBE/$CIRCLE_PROJECT_REPONAME/lcov.info -# else -# echo "Not a release (env CIRCLE_BRANCH != 'master'), skipping sending lcov.info to SonarQube." -# fi - -# test-integration: -# machine: true -# <<: *defaults_working_directory -# steps: -# - checkout -# - run: -# <<: *defaults_environment -# - restore_cache: -# key: dependency-cache-{{ checksum "package.json" }} -# - run: -# name: Create dir for test results -# command: mkdir -p ./test/results -# - run: -# name: Execute integration tests -# command: npm -s run test:integration -# no_output_timeout: 25m -# - store_artifacts: -# path: ./test/results -# prefix: test -# - store_test_results: -# path: ./test/results - -# test-functional: -# machine: true -# <<: *defaults_working_directory -# steps: -# - run: -# name: Add the Postgres 9.6 binaries to the path. -# command: echo "/usr/lib/postgresql/9.6/bin/:$PATH" >> $BASH_ENV -# - run: -# name: Install Docker Compose -# command: | -# curl -L https://github.com/docker/compose/releases/download/1.11.2/docker-compose-`uname -s`-`uname -m` > ~/docker-compose -# chmod +x ~/docker-compose -# mv ~/docker-compose /usr/local/bin/docker-compose -# - checkout -# - restore_cache: -# key: dependency-cache-{{ checksum "package.json" }} -# - run: -# name: Create dir for test results -# command: mkdir -p ./test/results -# - run: -# name: Execute functional tests -# command: npm -s run test:functional -# - store_artifacts: -# path: ./test/results -# prefix: test -# - store_test_results: -# path: ./test/results - -# test-spec: -# machine: true -# # <<: *defaults -# steps: -# # - run: -# # name: Install general dependencies -# # command: *defaultDependencies -# # - run: -# # name: Add the Postgres 9.6 binaries to the path. -# # command: apk --no-cache add postgresql-client -# # - setup_remote_docker -# # - run: -# # name: Add docker -# # command: apk --no-cache add docker -# # - run: -# # name: Add docker compose -# # command: | -# # apk --no-cache add py-pip -# # pip install docker-compose -# # - run: -# # name: Install Docker Compose -# # command: | -# # curl -L https://github.com/docker/compose/releases/download/1.8.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose; chmod +x /usr/local/bin/docker-compose -# - run: -# name: Add the Postgres 9.6 binaries to the path. -# command: echo "/usr/lib/postgresql/9.6/bin/:$PATH" >> $BASH_ENV -# - run: -# name: Install Docker Compose -# command: | -# curl -L https://github.com/docker/compose/releases/download/1.11.2/docker-compose-`uname -s`-`uname -m` > ~/docker-compose -# chmod +x ~/docker-compose -# mv ~/docker-compose /usr/local/bin/docker-compose -# - checkout -# - restore_cache: -# key: dependency-cache-{{ checksum "package.json" }} -# - run: -# name: Create dir for test results -# command: mkdir -p ./test/results -# - run: -# name: Execute unit tests -# command: npm -s run test:spec -# - store_artifacts: -# path: ./test/results -# prefix: test -# - store_test_results: -# path: ./test/results - -# vulnerability-check: -# <<: *defaults_working_directory -# <<: *defaults_docker_node -# steps: -# - run: -# name: Install general dependencies -# command: *defaults_Dependencies -# - checkout -# - restore_cache: -# key: dependency-cache-{{ checksum "package.json" }} -# - run: -# name: Create dir for test results -# command: mkdir -p ./audit/results -# - run: -# name: Check for new npm vulnerabilities -# command: npm run audit:check --silent -- --json > ./audit/results/auditResults.json -# - store_artifacts: -# path: ./audit/results -# prefix: audit -# -# audit-licenses: -# <<: *defaults_working_directory -# <<: *defaults_docker_node -# steps: -# - run: -# name: Install general dependencies -# command: *defaults_Dependencies -# - run: -# <<: *defaults_license_scanner -# - checkout -# - restore_cache: -# key: dependency-cache-{{ checksum "package.json" }} -# - run: -# name: Run the license-scanner -# command: cd /tmp/license-scanner && pathToRepo=$CIRCLE_WORKING_DIRECTORY make run -# - store_artifacts: -# path: /tmp/license-scanner/results -# prefix: licenses - - build-snapshot: - machine: true - <<: *defaults_working_directory + audit-licenses: + executor: default-docker steps: - - checkout - - run: - <<: *defaults_environment - - run: - name: setup environment vars for SNAPSHOT release - command: | - echo 'export RELEASE_TAG=$RELEASE_TAG_SNAPSHOT' >> $BASH_ENV - - run: - <<: *defaults_build_docker_login - run: - <<: *defaults_build_docker_build - - run: - <<: *defaults_build_docker_build_release - - run: - <<: *defaults_build_docker_publish - - run: - <<: *defaults_build_docker_publish_release + name: Install general dependencies + command: *defaults_Dependencies - run: - <<: *defaults_slack_announcement - - build-hotfix: - machine: true - # <<: *default_env - steps: + <<: *defaults_license_scanner - checkout + - restore_cache: + key: dependency-cache-5-{{ checksum "src/package.json" }} - run: - <<: *defaults_environment - - run: - name: setup environment vars for HOTFIX release - command: | - echo 'export RELEASE_TAG=$RELEASE_TAG_PROD' >> $BASH_ENV - - run: - <<: *defaults_build_docker_login - - run: - <<: *defaults_build_docker_build - - run: - <<: *defaults_build_docker_publish - - run: - <<: *defaults_slack_announcement + name: Run the license-scanner + command: cd /tmp/license-scanner && pathToRepo=$CIRCLE_WORKING_DIRECTORY make run + - store_artifacts: + path: /tmp/license-scanner/results + prefix: licenses build: - machine: true - # <<: *default_env + executor: default-machine steps: - checkout - run: - <<: *defaults_environment - - run: - name: setup environment vars for LATEST release + name: Build Docker $CIRCLE_TAG image command: | - echo 'export RELEASE_TAG=$RELEASE_TAG_PROD' >> $BASH_ENV + echo "Building Docker image: $CIRCLE_TAG" + docker build -t $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG . - run: - <<: *defaults_build_docker_login + name: Save docker image to workspace + command: docker save -o /tmp/docker-image.tar $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG + - persist_to_workspace: + root: /tmp + paths: + - ./docker-image.tar + + license-scan: + executor: default-machine + steps: + - attach_workspace: + at: /tmp - run: - <<: *defaults_build_docker_build -# - run: -# <<: *defaults_license_scanner -# - run: -# name: Run the license-scanner -# command: cd /tmp/license-scanner && mode=docker dockerImage=$DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG make run -# - store_artifacts: -# path: /tmp/license-scanner/results -# prefix: licenses + name: Load the pre-built docker image from workspace + command: docker load -i /tmp/docker-image.tar - run: - <<: *defaults_build_docker_build_release + <<: *defaults_license_scanner - run: - <<: *defaults_build_docker_publish + name: Run the license-scanner + command: cd /tmp/license-scanner && mode=docker dockerImages=$DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG make run + - store_artifacts: + path: /tmp/license-scanner/results + prefix: licenses + # Note: Disabled for now to investigate vulnerabilities without interfering with releases + # image-scan: + # executor: anchore/anchore_engine + # steps: + # - setup_remote_docker + # - checkout + # - run: + # name: Install docker dependencies for anchore + # command: | + # apk add --update python3 py3-pip docker python3-dev libffi-dev openssl-dev gcc libc-dev make jq npm + # - run: + # name: Install AWS CLI dependencies + # command: *defaults_awsCliDependencies + # - attach_workspace: + # at: /tmp + # - run: + # name: Load the pre-built docker image from workspace + # command: docker load -i /tmp/docker-image.tar + # - run: + # name: Download the mojaloop/ci-config repo + # command: | + # git clone https://github.com/mojaloop/ci-config /tmp/ci-config + # # Generate the mojaloop anchore-policy + # cd /tmp/ci-config/container-scanning && ./mojaloop-policy-generator.js /tmp/mojaloop-policy.json + # - run: + # name: Pull base image locally + # command: | + # docker pull node:12.16.1-alpine + # # Analyze the base and derived image + # # Note: It seems images are scanned in parallel, so preloading the base image result doesn't give us any real performance gain + # - anchore/analyze_local_image: + # # Force the older version, version 0.7.0 was just published, and is broken + # anchore_version: v0.6.1 + # image_name: "docker.io/node:12.16.1-alpine $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG" + # policy_failure: false + # timeout: '500' + # # Note: if the generated policy is invalid, this will fallback to the default policy, which we don't want! + # policy_bundle_file_path: /tmp/mojaloop-policy.json + # - run: + # name: Upload Anchore reports to s3 + # command: | + # aws s3 cp anchore-reports ${AWS_S3_DIR_ANCHORE_REPORTS}/${CIRCLE_PROJECT_REPONAME}/ --recursive + # aws s3 rm ${AWS_S3_DIR_ANCHORE_REPORTS}/latest/ --recursive --exclude "*" --include "${CIRCLE_PROJECT_REPONAME}*" + # aws s3 cp anchore-reports ${AWS_S3_DIR_ANCHORE_REPORTS}/latest/ --recursive + # - run: + # name: Evaluate failures + # command: /tmp/ci-config/container-scanning/anchore-result-diff.js anchore-reports/node_12.16.1-alpine-policy.json anchore-reports/${CIRCLE_PROJECT_REPONAME}*-policy.json + # - slack/status: + # fail_only: true + # webhook: "$SLACK_WEBHOOK_ANNOUNCMENT_CI_CD" + # failure_message: 'Anchore Image Scan failed for: \`"${DOCKER_ORG}/${CIRCLE_PROJECT_REPONAME}:${CIRCLE_TAG}"\`' + # - store_artifacts: + # path: anchore-reports + + publish: + executor: default-machine + steps: + - checkout + - attach_workspace: + at: /tmp - run: - <<: *defaults_build_docker_publish_release + name: Load the pre-built docker image from workspace + command: docker load -i /tmp/docker-image.tar - run: - <<: *defaults_npm_auth + name: Login to Docker Hub + command: docker login -u $DOCKER_USER -p $DOCKER_PASS - run: - <<: *defaults_npm_publish_release + name: Re-tag pre built image + command: | + docker tag $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG - run: - <<: *defaults_slack_announcement - -# deploy-snapshot: -# <<: *defaults_working_directory -# <<: *defaults_docker_helm_kube -# steps: -# - run: -# <<: *defaults_environment -# - run: -# name: Install AWS CLI dependencies -# command: *defaults_awsCliDependencies -# - run: -# name: setup environment vars for SNAPSHOT release -# command: | -# echo 'export HELM_VALUE_FILENAME=$K8_HELM_VALUE_FILENAME_SNAPSHOT' >> $BASH_ENV -# echo 'export K8_CLUSTER_SERVER=$K8_CLUSTER_SERVER_SNAPSHOT' >> $BASH_ENV -# echo 'export K8_RELEASE_NAME=$K8_RELEASE_NAME_SNAPSHOT' >> $BASH_ENV -# echo 'export K8_NAMESPACE=$K8_NAMESPACE_SNAPSHOT' >> $BASH_ENV -# echo 'export K8_USER_NAME=$K8_USER_NAME_SNAPSHOT' >> $BASH_ENV -# echo 'export K8_USER_TOKEN=$K8_USER_TOKEN_SNAPSHOT' >> $BASH_ENV -# echo 'export K8_HELM_CHART_NAME=$K8_HELM_CHART_NAME_SNAPSHOT' >> $BASH_ENV -# echo 'export K8_HELM_CHART_VERSION=$K8_HELM_CHART_VERSION_SNAPSHOT' >> $BASH_ENV -# echo 'export HELM_VALUE_SET_VALUES="--set central.centralhub.centralledger.containers.api.image.repository=$DOCKER_ORG/$CIRCLE_PROJECT_REPONAME --set central.centralhub.centralledger.containers.api.image.tag=$CIRCLE_TAG --set central.centralhub.centralledger.containers.admin.image.repository=$DOCKER_ORG/$CIRCLE_PROJECT_REPONAME --set central.centralhub.centralledger.containers.admin.image.tag=$CIRCLE_TAG"' >> $BASH_ENV -# - run: -# <<: *defaults_deploy_prequisites -# - run: -# <<: *defaults_deploy_config_kubernetes_cluster -# - run: -# <<: *defaults_deploy_config_kubernetes_credentials -# - run: -# <<: *defaults_deploy_config_kubernetes_context -# - run: -# <<: *defaults_deploy_set_kubernetes_context -# - run: -# <<: *defaults_deploy_configure_helm -# - run: -# <<: *defaults_deploy_install_or_upgrade_helm_chart -# -# deploy: -# <<: *defaults_working_directory -# <<: *defaults_docker_helm_kube -# steps: -# - run: -# <<: *defaults_environment -# - run: -# name: Install AWS CLI dependencies -# command: *defaults_awsCliDependencies -# - run: -# name: setup environment vars for release -# command: | -# echo 'export HELM_VALUE_FILENAME=$K8_HELM_VALUE_FILENAME_PROD' >> $BASH_ENV -# echo 'export K8_CLUSTER_SERVER=$K8_CLUSTER_SERVER_PROD' >> $BASH_ENV -# echo 'export K8_RELEASE_NAME=$K8_RELEASE_NAME_PROD' >> $BASH_ENV -# echo 'export K8_NAMESPACE=$K8_NAMESPACE_PROD' >> $BASH_ENV -# echo 'export K8_USER_NAME=$K8_USER_NAME_PROD' >> $BASH_ENV -# echo 'export K8_USER_TOKEN=$K8_USER_TOKEN_PROD' >> $BASH_ENV -# echo 'export K8_HELM_CHART_NAME=$K8_HELM_CHART_NAME_PROD' >> $BASH_ENV -# echo 'export K8_HELM_CHART_VERSION=$K8_HELM_CHART_VERSION_PROD' >> $BASH_ENV -# echo 'export HELM_VALUE_SET_VALUES="--set central.centralhub.centralledger.containers.api.image.repository=$DOCKER_ORG/$CIRCLE_PROJECT_REPONAME --set central.centralhub.centralledger.containers.api.image.tag=$CIRCLE_TAG --set central.centralhub.centralledger.containers.admin.image.repository=$DOCKER_ORG/$CIRCLE_PROJECT_REPONAME --set central.centralhub.centralledger.containers.admin.image.tag=$CIRCLE_TAG"' >> $BASH_ENV -# - run: -# <<: *defaults_deploy_prequisites -# - run: -# <<: *defaults_deploy_config_kubernetes_cluster -# - run: -# <<: *defaults_deploy_config_kubernetes_credentials -# - run: -# <<: *defaults_deploy_config_kubernetes_context -# - run: -# <<: *defaults_deploy_set_kubernetes_context -# - run: -# <<: *defaults_deploy_configure_helm -# - run: -# <<: *defaults_deploy_install_or_upgrade_helm_chart + name: Publish Docker image $CIRCLE_TAG & Latest tag to Docker Hub + command: | + echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG" + docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG + echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" + docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG + - slack/status: + webhook: "$SLACK_WEBHOOK_ANNOUNCEMENT" + success_message: '*"${CIRCLE_PROJECT_REPONAME}"* - Release \`"${CIRCLE_TAG}"\` \nhttps://github.com/mojaloop/"${CIRCLE_PROJECT_REPONAME}"/releases/tag/"${CIRCLE_TAG}"' workflows: version: 2 @@ -577,40 +312,6 @@ workflows: ignore: - /feature*/ - /bugfix*/ - -# - test-coverage: -# context: org-global -# requires: -# - setup -# filters: -# tags: -# only: /.*/ -# branches: -# ignore: -# - /feature*/ -# - /bugfix*/ -# - vulnerability-check: -# context: org-global -# requires: -# - setup -# filters: -# tags: -# only: /.*/ -# branches: -# ignore: -# - /feature*/ -# - /bugfix*/ -# - audit-licenses: -# context: org-global -# requires: -# - setup -# filters: -# tags: -# only: /.*/ -# branches: -# ignore: -# - /feature*/ -# - /bugfix*/ - test-integration: context: org-global requires: @@ -622,99 +323,59 @@ workflows: ignore: - /feature*/ - /bugfix*/ -# - test-functional: -# context: org-global -# requires: -# - setup -# filters: -# tags: -# only: /.*/ -# branches: -# ignore: -# - /feature*/ -# - /bugfix*/ -# - test-spec: -# context: org-global -# requires: -# - setup -# filters: -# tags: -# only: /.*/ -# branches: -# ignore: -# - /feature*/ -# - /bugfix*/ - - build-snapshot: + - audit-licenses: context: org-global requires: - setup - - test-unit - - lint - - test-integration -# - test-coverage -# - test-functional -# - test-spec -# - vulnerability-check -# - audit-licenses filters: tags: - only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)/ + only: /.*/ branches: ignore: - - /.*/ -# - deploy-snapshot: -# context: org-global -# requires: -# - build-snapshot -# filters: -# tags: -# only: /v[0-9]+(\.[0-9]+)*\-snapshot/ -# branches: -# ignore: -# - /.*/ + - /feature*/ + - /bugfix*/ - build: context: org-global requires: - setup - test-unit - - lint - test-integration -# - test-coverage -# - test-functional -# - test-spec -# - vulnerability-check -# - audit-licenses + - lint + - audit-licenses filters: tags: - only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)/ + only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)?/ branches: ignore: - /.*/ - - build-hotfix: + - license-scan: context: org-global requires: - - setup - - test-unit - - lint - - test-integration -# - test-coverage -# - test-functional -# - test-spec -# - vulnerability-check -# - audit-licenses + - build + filters: + tags: + only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)?/ + branches: + ignore: + - /.*/ + # Note: Disabled for now to investigate vulnerabilities without interfering with releases + # - image-scan: + # context: org-global + # requires: + # - build + # filters: + # tags: + # only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?/ + # branches: + # ignore: + # - /.*/ + - publish: + context: org-global + requires: + - license-scan filters: tags: - only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)/ + only: /v[0-9]+(\.[0-9]+)*(\-snapshot)?(\-hotfix(\.[0-9]+))?(\-pisp)?/ branches: ignore: - /.*/ -# - deploy: -# context: org-global -# requires: -# - build -# filters: -# tags: -# only: /v[0-9]+(\.[0-9]+)*/ -# branches: -# ignore: -# - /.*/ diff --git a/Dockerfile b/Dockerfile index f901f010a..91427653a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.14.0-alpine as builder +FROM node:12.16.1-alpine as builder RUN apk add --no-cache git python build-base @@ -20,7 +20,7 @@ COPY ./src/lib/router/package.json ./lib/router/package.json COPY ./src/lib/validate/package.json ./lib/validate/package.json RUN npm install -FROM node:12.14.0-alpine +FROM node:12.16.1-alpine ARG BUILD_DATE ARG VCS_URL From 456fedc6749fe541fc30bd8f11da37bb02b2da2b Mon Sep 17 00:00:00 2001 From: eoln <2881004+eoln@users.noreply.github.com> Date: Wed, 1 Jul 2020 13:59:47 +0200 Subject: [PATCH 07/29] implement POST /authorization on OutbondService (#170) * chore(uml): add sequence * chore(gitignore): skip tracking coverage and linting cache * feat(authorizations): first draft of api.yaml for OutboundServer * feat(model/common): simplify toJSON method * chore(uml): add cache part to better understand the flow * feat: Draft of Outbond Authhorization Model * chore(uml): extract pisp sequences to separate file * chore(.gitignore): do not trace .DS_Store * feat(OutbondAuthorizationsModel): implement unit tests * chore: fix linting * feat(OutboundServer/handlers): implement postAuthorization * feat(Outbound/handlers): postAuthorization * chore(uml): thirdparty flows * feat(Inbound/handlers): putAuthorization and ENABLE_PISP_MODE * fix(OutboundAuthorizationsModel.test): fix validation test * test(Inbound/handlers): mockLogger * refactor(PersistenStateMachine): make code simpler * fix(mockLogger): improper mock layout * refactor(OutboundAuthorizationModel): use mockLoggger & make code simpler * test(PUT /authorizations): implement DFSP & PISP tests * refactor(InboundServer/handlers): simplify code * test(OutboundServer/handlers): post /authorizations * fix(OpenAPI): finalise schemas for inbound and outbound servers --- .gitignore | 1 + pisp-sequences.puml | 148 +++++++ sequences.puml | 5 +- src/.gitignore | 3 + src/InboundServer/api.yaml | 32 +- src/InboundServer/handlers.js | 17 +- src/OutboundServer/api.yaml | 212 +++++++++- src/OutboundServer/handlers.js | 44 ++- src/config.js | 1 + src/lib/model/OutboundAuthorizationsModel.js | 264 +++++++++++++ .../{common.js => common/BackendError.js} | 20 +- .../model/common/PersistentStateMachine.js | 94 +++++ src/lib/model/common/index.js | 18 + src/lib/model/index.js | 7 +- .../@mojaloop/sdk-standard-components.js | 3 + src/test/unit/inboundApi/handlers.test.js | 115 +++++- .../model/OutboundAuthorizationsModel.test.js | 365 ++++++++++++++++++ .../common/PersistentStateMachine.test.js | 202 ++++++++++ src/test/unit/mockLogger.js | 29 ++ src/test/unit/outboundApi/handlers.test.js | 62 ++- 20 files changed, 1587 insertions(+), 55 deletions(-) create mode 100644 pisp-sequences.puml create mode 100644 src/.gitignore create mode 100644 src/lib/model/OutboundAuthorizationsModel.js rename src/lib/model/{common.js => common/BackendError.js} (72%) create mode 100644 src/lib/model/common/PersistentStateMachine.js create mode 100644 src/lib/model/common/index.js create mode 100644 src/test/unit/lib/model/OutboundAuthorizationsModel.test.js create mode 100644 src/test/unit/lib/model/common/PersistentStateMachine.test.js create mode 100644 src/test/unit/mockLogger.js diff --git a/.gitignore b/.gitignore index 35783b086..d3a8ba6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .swp src/junit.xml +.DS_Store \ No newline at end of file diff --git a/pisp-sequences.puml b/pisp-sequences.puml new file mode 100644 index 000000000..66ecddaa2 --- /dev/null +++ b/pisp-sequences.puml @@ -0,0 +1,148 @@ +@startuml +title Mojaloop PISP SDK Interactions +autonumber + +participant "DFSP Backend" as backend +participant "PISP Backend" as pisp + +box "SDK" + participant "SDK Inbound Service" as sib + participant "Cache" as cache + participant "SDK Outbound Service" as sob +end box + +participant "Switch" as sw + + + +note over sib, sob: All outbound between SDK and Switch have\nJWS added automatically +note over sib, sob: All inbound from switch have JWS validated + +note over sib, sob: Mapping between scheme transaction types and DFSP\ntransaction types +note over sib, sob: JWS and TLS keys managed by SDK + +== PISP receives autorization request == + sw -> sib: POST /authorization + sib -> pisp: POST /signedCondition + pisp -> pisp: ask User for authorization + pisp -> sib: in respons body we have signed condition + sib -> sib: prepare response to be send via PUT /authorization/{id} + sib -> sw: PUT /authorization/{id} +== PISP is notified about transaction status == + sw -> sib: PUT /thridPartyRequests/{id} + sib -> cache: cache.publish('thridparty_requests_{id}', response.body) +== PISP is notified about transaction error == + sw -> sib: PUT /thridPartyRequests/{id}/error + sib -> cache: cache.publish('thridparty_requests_error_{id}', response.body) +== DFSP requests authorization == + rnote right of backend #Light + **""POST /authorizations""** + ""FSPIOP-Source: DFSP Backend"" + ""FSPIOP-Destination: PISP"" + { + "authenticationType": "U2F", + "retriesLeft": "1", + "amount": { + "amount": "100", + "currency": "USD" + }, + "transactionId": "987", + **"transactionRequestId": "123"**, + "quote": { + "transferAmount": { + "amount": "100", + "currency": "USD" + }, + "payeeReceiveAmount": { + "amount": "99", + "currency": "USD" + }, + "payeeFspFee": { + "amount": "1", + "currency": "USD" + }, + "expiration": "2020-06-15T12:00:00.000", + "ilpPacket": "...", + "condition": "...", + } + } + end note + backend -> sob: POST /authorizations + sob -> cache: notification_handler = cache.subscribe('authorization/id') + rnote right of sob #Light + **""POST /authorizations""** + ""FSPIOP-Source: DFSP Backend"" + ""FSPIOP-Destination: PISP"" + { + "authenticationType": "U2F", + "retriesLeft": "1", + "amount": { + "amount": "100", + "currency": "USD" + }, + "transactionId": "987", + **"transactionRequestId": "123"**, + "quote": { + "transferAmount": { + "amount": "100", + "currency": "USD" + }, + "payeeReceiveAmount": { + "amount": "99", + "currency": "USD" + }, + "payeeFspFee": { + "amount": "1", + "currency": "USD" + }, + "expiration": "2020-06-15T12:00:00.000", + "ilpPacket": "...", + "condition": "...", + } + } + end note + sob -> sw: POST /authorizations + sw -> sw: request authorization\nfrom PISP's customer\nvia auth-service + rnote left of sw #Light + **""PUT /authorizations/123""** + ""FSPIOP-Source: PISP"" + ""FSPIOP-Destination: DFSP Backend"" + { + "authenticationInfo": { + "authentication": "U2F", + "authenticationValue": { + "pinValue": "", + "counter": "1" + } + } + "responseType": "ENTERED" + } + end note + sw -> sib: PUT /authorizations/**123** + sib -> cache: cache.publish('authorization/{id}', put.body) + cache -> sob: notification('authorization/{id}', put.body) + sob -> sob: notification_handler('authorization/{id}', put.body) + rnote left of sob #Light + **""Response body of POST /authorizations""** + { + "transactionRequestId": "123", + "authenticationInfo": { + "authentication": "U2F", + "authenticationValue": { + "pinValue": "", + "counter": "1" + } + } + "responseType": "ENTERED" + } + end note + sob -> backend: OK authorization details + backend -> backend: verify authorization + alt PROPOSAL: accepting authorization + backend -> sob: OK: PUT /authorizations/**123** + sob -> sw: PUT /authorizations/**123** + else PROPOSAL: not accepting authorization + backend -> sob: NOK: PUT /authorizations/**123**/error + sob -> sw: PUT /authorizations/**123**/error + end +@enduml diff --git a/sequences.puml b/sequences.puml index 832ca2427..0e592322a 100644 --- a/sequences.puml +++ b/sequences.puml @@ -9,10 +9,10 @@ participant "ESB/PortX" as esb box "SDK" participant "SDK Inbound Service" as sib + participant "Cache" as cache participant "SDK Outbound Service" as sob end box - participant "Switch" as sw @@ -94,6 +94,3 @@ note over sib, sob: JWS and TLS keys managed by SDK end - -@enduml - diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 000000000..63177638d --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,3 @@ +coverage +.eslintcache +.DS_Store \ No newline at end of file diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index 11af0e59b..6bb9f490a 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -2862,17 +2862,40 @@ components: enum: - OTP - QRCODE + - U2F description: >- Below are the allowed values for the enumeration AuthenticationType. - OTP One-time password generated by the Payer FSP. - QRCODE QR code used as One Time Password. + AuthenticationValue: title: AuthenticationValue + oneOf: + - $ref: '#/components/schemas/OtpValue' + - $ref: '#/components/schemas/QRCODE' + - $ref: '#/components/schemas/U2FPinValue' + description: Contains the authentication value. The format depends on the authentication type used in the AuthenticationInfo complex type. + U2FPinValue: + title: U2FPinValue + type: object + description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. + properties: + pinValue: + $ref: '#/components/schemas/U2FPIN' + description: U2F challenge-response. + counter: + $ref: '#/components/schemas/Integer' + description: Sequential counter used for cloning detection. Present only for U2F authentication. + required: + - pinValue + - counter + U2FPIN: + title: U2FPIN type: string - pattern: '^\d{3,10}$|^\S{1,64}$' - description: >- - Contains the authentication value. The format depends on the - authentication type used in the AuthenticationInfo complex type. + pattern: ^\S{1,64}$ + minLength: 1 + maxLength: 64 + description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. AuthorizationResponse: title: AuthorizationResponse type: string @@ -3383,6 +3406,7 @@ components: QRCODE: title: QRCODE type: string + pattern: ^\S{1,64}$ minLength: 1 maxLength: 64 description: QR code used as One Time Password. diff --git a/src/InboundServer/handlers.js b/src/InboundServer/handlers.js index 4e05276d4..75ed94e3b 100644 --- a/src/InboundServer/handlers.js +++ b/src/InboundServer/handlers.js @@ -6,13 +6,14 @@ * * * ORIGINAL AUTHOR: * * James Bush - james.bush@modusbox.com * + * Paweł Marzec - pawel.marzec@modusbox.com * **************************************************************************/ 'use strict'; const util = require('util'); const Model = require('@internal/model').InboundTransfersModel; - +const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; /** * Handles a GET /authorizations/{id} request */ @@ -334,7 +335,7 @@ const postTransactionRequests = async (ctx) => { }; /** - * Handles a PUT /authorizations/{id}. This is a response to a GET /authorizations/{ID} + * Handles a PUT /authorizations/{id}. This is a response to a GET /authorizations/{ID} or POST /authorizations/{ID} * request. */ const putAuthorizationsById = async (ctx) => { @@ -345,19 +346,21 @@ const putAuthorizationsById = async (ctx) => { data: ctx.request.body }; const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } const idValue = ctx.state.path.params.ID; - // publish an event onto the cache for subscribers to action - const cacheId = `otp_${idValue}`; - // publish an event onto the cache for subscribers to action - await ctx.state.cache.publish(cacheId, { + const authorizationChannel = ctx.state.conf.enablePISPMode + ? AuthorizationsModel.notificationChannel(idValue) + : `otp_${ctx.state.path.params.ID}`; + + await ctx.state.cache.publish(authorizationChannel, { type: 'authorizationsResponse', data: ctx.request.body, headers: ctx.request.headers }); + ctx.response.status = 200; }; diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index c37d37252..c4cd7bd00 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -26,6 +26,7 @@ paths: responses: 200: description: Returns empty body if the scheme adapter outbound transfers service is running. + /transfers: post: summary: Sends money from one account to another @@ -260,6 +261,33 @@ paths: 504: $ref: '#/components/responses/accountsCreationTimeout' + /authorizations: + post: + tags: + - authorizations + - sampled + description: The HTTP request `POST /authorizations` is used to request the Payer to enter the applicable credentials in the PISP system. + summary: /authorizations + operationId: AuthorizationsPost + requestBody: + description: Perform authorization + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/authorizationsRequest' + responses: + 200: + $ref: '#/components/responses/authorizationsResponse' + # TODO: define when ready to + # 400: + # $ref: '#/components/responses/authorizationsPostBadRequest' + # 500: + # $ref: '#/components/responses/authorizationsPostServerError' + # 504: + # $ref: '#/components/responses/authorizationsPostTimeout' + + components: schemas: @@ -452,7 +480,7 @@ components: message: type: string description: Error message text. - + errorTransferResponse: allOf: - $ref: '#/components/schemas/errorResponse' @@ -572,7 +600,6 @@ components: - OTHER_ID - Any other type of identification type number is used in reference to a party. - money: pattern: ^([0]|([1-9][0-9]{0,17}))([.][0-9]{0,3}[1-9])?$ type: string @@ -917,7 +944,6 @@ components: or an object representing other types of error e.g. exceptions that may occur inside the scheme adapter. $ref: '#/components/schemas/transferError' - transferResponse: type: object required: @@ -1046,7 +1072,73 @@ components: description: 'Optional extension, specific to deployment.' required: - transferState + + mojaloopAuthenticationType: + title: AuthenticationType + type: string + enum: + - OTP + - QRCODE + - U2F + description: Below are the allowed values for the enumeration AuthenticationType. + + mojaloopAuthenticationValue: + title: AuthenticationValue + oneOf: + - $ref: '#/components/schemas/mojaloopOtpValue' + - $ref: '#/components/schemas/mojaloopQRCODE' + - $ref: '#/components/schemas/mojaloopU2FPinValue' + description: Contains the authentication value. The format depends on the authentication type used in the AuthenticationInfo complex type. + + mojaloopOtpValue: + title: OtpValue + type: string + pattern: '^\d{3,10}$' + mojaloopU2FPinValue: + title: U2FPinValue + type: object + description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. + properties: + pinValue: + $ref: '#/components/schemas/mojaloopU2FPIN' + description: U2F challenge-response. + counter: + $ref: '#/components/schemas/mojaloopInteger' + description: Sequential counter used for cloning detection. Present only for U2F authentication. + required: + - pinValue + - counter + + mojaloopU2FPIN: + title: U2FPIN + type: string + pattern: ^\S{1,64}$ + minLength: 1 + maxLength: 64 + description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. + + mojaloopQRCODE: + title: QRCODE + type: string + pattern: ^\S{1,64}$ + minLength: 1 + maxLength: 64 + description: QR code used as One Time Password. + + mojaloopAuthenticationInfo: + title: AuthenticationInfo + description: Data model for the complex type AuthenticationInfo + required: + - authentication + - authenticationValue + type: object + properties: + authentication: + $ref: '#/components/schemas/mojaloopAuthenticationType' + authenticationValue: + $ref: '#/components/schemas/mojaloopAuthenticationValue' + mojaloopTransferState: type: string enum: @@ -1075,6 +1167,27 @@ components: sent the transaction request to the Payer. - ACCEPTED Payer has approved the transaction. - REJECTED Payer has rejected the transaction. + mojaloopCorrelationId: + title: CorrelationId + description: >- + Identifier that correlates all messages of the same sequence. The API + data type UUID (Universally Unique Identifier) is a JSON String in + canonical format, conforming to RFC 4122, that is restricted by a + regular expression for interoperability reasons. An UUID is always 36 + characters long, 32 hexadecimal symbols and 4 dashes (‘-‘). + pattern: >- + ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + type: string + + mojaloopInteger: + title: Integer + description: >- + The API data type Integer is a JSON String consisting of digits only. + Negative numbers and leading zeroes are not allowed. The data type is + always limited to a specific number of digits. + pattern: '^[1-9]\d*$' + type: string + mojaloopMoney: title: Money type: object @@ -1089,6 +1202,41 @@ components: required: - currency - amount + + mojaloopQuotesIDPutResponse: + title: QuotesIDPutResponse + description: 'PUT /quotes/{ID} object' + required: + - transferAmount + - expiration + - ilpPacket + - condition + type: object + additionalProperties: false + properties: + transferAmount: + $ref: '#/components/schemas/mojaloopMoney' + payeeReceiveAmount: + $ref: '#/components/schemas/mojaloopMoney' + payeeFspFee: + $ref: '#/components/schemas/mojaloopMoney' + payeeFspCommission: + $ref: '#/components/schemas/mojaloopMoney' + expiration: + description: >- + Date and time until when the quotation is valid and can be honored + when used in the subsequent transaction. + type: string + geoCode: + $ref: '#/components/schemas/geoCode' + ilpPacket: + description: The ILP Packet that must be attached to the transfer by the Payer. + type: string + condition: + description: The condition that must be attached to the transfer by the Payer. + type: string + extensionList: + $ref: '#/components/schemas/extensionList' transferError: type: object @@ -1262,7 +1410,53 @@ components: $ref: '#/components/schemas/accountsCreationState' lastError: $ref: '#/components/schemas/transferError' - + + authorizationsRequest: + title: authorizationsRequest + description: POST /authorizations Request object + type: object + properties: + authenticationType: + $ref: '#/components/schemas/mojaloopAuthenticationType' + retriesLeft: + $ref: '#/components/schemas/mojaloopInteger' + amount: + $ref: '#/components/schemas/mojaloopMoney' + transactionId: + $ref: '#/components/schemas/mojaloopCorrelationId' + transactionRequestId: + $ref: '#/components/schemas/mojaloopCorrelationId' + quote: + $ref: '#/components/schemas/mojaloopQuotesIDPutResponse' + required: + - authenticationType + - retriesLeft + - amount + - transactionId + - transactionRequestId + - quote + additionalProperties: false + + authorizationsResponse: + title: authorizationsResponse + description: | + response body of POST/authorizations + derived from AuthorizationsIDPutResponse + type: object + required: + - responseType + additionalProperties: false + properties: + authenticationInfo: + $ref: '#/components/schemas/mojaloopAuthenticationInfo' + responseType: + description: >- + Enum containing response information; if the customer entered the + authentication value, rejected the transaction, or requested a + resend of the authentication value. + TODO: enum specification will be defined when all values will be known + type: string + responses: transferSuccess: description: Transfer completed successfully @@ -1325,8 +1519,14 @@ components: application/json: schema: $ref: '#/components/schemas/errorTransferResponse' - - + + authorizationsResponse: + description: authorization response + content: + application/json: + schema: + $ref: '#/components/schemas/authorizationsResponse' + parameters: transferId: diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index 576d8a260..fed2aa5bf 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -12,7 +12,13 @@ const util = require('util'); -const { AccountsModel, OutboundTransfersModel, OutboundRequestToPayTransferModel, OutboundRequestToPayModel } = require('@internal/model'); +const { + AccountsModel, + OutboundTransfersModel, + OutboundRequestToPayTransferModel, + OutboundRequestToPayModel, + OutboundAuthorizationsModel +} = require('@internal/model'); /** @@ -69,6 +75,8 @@ const handleRequestToPayError = (method, err, ctx) => const handleRequestToPayTransferError = (method, err, ctx) => handleError(method, err, ctx, 'requestToPayTransferState'); +const handleAuthorizationsError = (method, err, ctx) => + handleError(method, err, ctx, 'authorizationsState'); /** * Handler for outbound transfer request initiation @@ -294,6 +302,37 @@ const healthCheck = async (ctx) => { ctx.response.body = ''; }; +const postAuthorizations = async (ctx) => { + try { + // prepare request + const authorizationsRequest = { + ...ctx.request.body + }; + + // prepare config + const modelConfig = { + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }; + + // use the authorizations model to execute asynchronous stages with the switch + const model = await OutboundAuthorizationsModel.create(authorizationsRequest, modelConfig); + + // run model's workflow + const response = await model.run(); + + // return the result + ctx.response.status = 200; + ctx.response.body = response; + + } catch(err) { + return handleAuthorizationsError('postAuthorizations', err, ctx); + } +}; + + module.exports = { '/': { get: healthCheck @@ -317,4 +356,7 @@ module.exports = { '/requestToPayTransfer/{requestToPayTransactionId}': { put: putRequestToPayTransfer }, + '/authorizations' : { + post: postAuthorizations + } }; diff --git a/src/config.js b/src/config.js index 9a046ba58..14cb76362 100644 --- a/src/config.js +++ b/src/config.js @@ -129,4 +129,5 @@ module.exports = { outboundErrorStatusCodeExtensionKey: env.get('OUTBOUND_ERROR_STATUSCODE_EXTENSION_KEY').asString(), proxyConfig: env.get('PROXY_CONFIG_PATH').asYamlConfig(), + enablePISPMode: env.get('ENABLE_PISP_MODE').default('false').asBool() }; diff --git a/src/lib/model/OutboundAuthorizationsModel.js b/src/lib/model/OutboundAuthorizationsModel.js new file mode 100644 index 000000000..e6d0c80a3 --- /dev/null +++ b/src/lib/model/OutboundAuthorizationsModel.js @@ -0,0 +1,264 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +'use strict'; + +const util = require('util'); +const { uuid } = require('uuidv4'); +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const PSM = require('./common').PersistentStateMachine; + + +const specStateMachine = { + init: 'start', + transitions: [ + { name: 'init', from: 'none', to: 'start' }, + { name: 'requestAuthorization', from: 'start', to: 'waitingForAuthorization' }, + { name: 'authorizationReceived', from: 'waitingForAuthorization', to: 'succeeded'}, + { name: 'error', from: '*', to: 'errored' }, + ], + methods: { + // workflow methods + run, + getResponse, + + // specific transitions handlers methods + onRequestAuthorization, + onAuthorizationReceived + } +}; + +/** + * runs the workflow + */ +async function run(message) { + const { data, logger } = this.context; + try { + // run transitions based on incoming state + switch(data.currentState) { + case 'start': + // the first transition is requestAuthorization + await this.requestAuthorization(); + logger.log(`Authorization requested for ${data.transactionRequestId}`); + return this.getResponse(); + + case 'waitingForAuthorization': + await this.authorizationReceived(message); + logger.log(`Authorization received for ${data.transactionRequestId}`); + return this.getResponse(); + + case 'succeeded': + // all steps complete so return + logger.log('Authorization completed successfully'); + return this.getResponse(); + + case 'errored': + // stopped in errored state + logger.log('State machine in errored state'); + return; + } + + // now call ourselves recursively to deal with the next transition + // in this scenario defined in switch statement ^ this part of code is not reachable because of return in every case !!! + // logger.log(`Authorization model state machine transition completed in state: ${this.state}. Recursing to handle next transition.`); + // return run(); + + } catch (err) { + logger.log(`Error running authorizations model: ${util.inspect(err)}`); + + // as this function is recursive, we don't want to error the state machine multiple times + if(data.currentState !== 'errored') { + // err should not have a authorizationState property here! + if(err.authorizationState) { + logger.log('State machine is broken'); + } + // transition to errored state + await this.error(err); + + // avoid circular ref between authorizationState.lastError and err + err.authorizationState = JSON.parse(JSON.stringify(this.getResponse())); + } + throw err; + } +} + + +const mapCurrentState = { + start: 'WAITING_FOR_AUTHORIZATION_REQUEST', + waitingForAuthorization: 'WAITING_FOR_AUTHORIZATION_RESPONSE', + succeeded: 'COMPLETED', + errored: 'ERROR_OCCURRED' +}; + + +/** + * Returns an object representing the final state of the authorization suitable for the outbound API + * + * @returns {object} - Response representing the result of the authorization process + */ +function getResponse() { + const { data, logger } = this.context; + let resp = { ...data }; + + // project some of our internal state into a more useful + // representation to return to the SDK API consumer + resp.currentState = mapCurrentState[data.currentState]; + + // handle unexpected state + if(!resp.currentState) { + logger.log(`Authorization model response being returned from an unexpected state: ${data.currentState}. Returning ERROR_OCCURRED state`); + resp.currentState = mapCurrentState.errored; + } + + return resp; +} + +function notificationChannel(id) { + // mvp validation + if(!(id && id.toString().length > 0)) { + throw new Error('OutboundAuthorizationsModel.notificationChannel: \'id\' parameter is required'); + } + + // channel name + return `authorizations_${id}`; +} + +/** + * Requests Authorization + * Starts the authorization process by sending a POST /authorizations request to switch; + * than await for a notification on PUT /authorizations/ from the cache that the Authorization has been resolved + */ +async function onRequestAuthorization() { + const { data, cache, logger } = this.context; + const { requests, config } = this.handlersContext; + const channel = notificationChannel(data.transactionRequestId); + let subId; + + try { + // in InboundServer/handlers is implemented putAuthorizationsById handler where this event is fired + subId = await cache.subscribe(channel, async (channel, message, sid) => { + try { + await this.run(message); + // there is no need to block execution using await here + } finally { + cache.unsubscribe(sid); + } + + }); + + // POST /authorization request to the switch + const postRequest = buildPostAuthorizationsRequest(data, config); + + // TODO: postAuthorizations is mocked method until this feature arrive in MojaloopRequests + const res = await requests.postAuthorizations(postRequest); + + logger.push({ res }).log('Authorizations request sent to peer'); + + } catch(error) { + cache.unsubscribe(subId); + throw error; + } +} + + +/** + * Propagates the Authorization + * we got the notification on PUT /authorizations/ @ InboundServer + * so we can propagate it back to DFSP + * + * + */ +async function onAuthorizationReceived(message) { + // mvp validation + if(!(message && typeof message === 'object' && message.body && typeof message.body === 'object' )) { + throw new Error('OutboundAuthorizationsModel.onAuthorizationReceived: invalid \'message\' parameter is required'); + } + this.context.data = message.body; +} + + +function buildPostAuthorizationsRequest(data/** , config */) { + // TODO: the request object must be valid to schema defined in sdk-standard-components + const request = { + ...data + }; + + return request; +} + +/** + * injects the config into state machine data + * so it will be accessible to on transition notification handlers via `this.handlersContext` + * + * @param {Object} config - config to be injected into state machine data + * @param {Object} specStateMachine - specState machine to be altered + * @returns {Object} - the altered specStateMachine + */ +function injectHandlersContext(config, specStateMachine) { + // TODO: postAuthorizations is a mocked method until this feature arrive in MojaloopRequests + return { + ...specStateMachine, + data: { + handlersContext: { + config, // injects config property + requests: new MojaloopRequests({ + logger: config.logger, + peerEndpoint: config.peerEndpoint, + alsEndpoint: config.alsEndpoint, + dfspId: config.dfspId, + tls: config.tls, + jwsSign: config.jwsSign, + jwsSignPutParties: config.jwsSignPutParties, + jwsSigningKey: config.jwsSigningKey, + wso2Auth: config.wso2Auth + }) + } + } + }; +} + + +/** + * creates a new instance of state machine specified in specStateMachine ^ + * + * @param {Object} data - payload data + * @param {String} key - the cache key where state machine will store the payload data after each transition + * @param {Object} config - the additional configuration for transition handlers + */ +async function create(data, key, config) { + + if(!data.hasOwnProperty('transactionRequestId')) { + data.transactionRequestId = uuid(); + } + + const spec = injectHandlersContext(config, specStateMachine); + return PSM.create(data, config.cache, key, config.logger, spec); +} + + +/** + * loads state machine from cache by given key and specify the additional config for transition handlers + * @param {String} key - the cache key used to retrieve the state machine from cache + * @param {Object} config - the additional configuration for transition handlers + */ +async function loadFromCache(key, config) { + const customCreate = async (data, cache, key /**, logger, stateMachineSpec **/) => create(data, key, config); + return PSM.loadFromCache(config.cache, key, config.logger, specStateMachine, customCreate); +} + +module.exports = { + create, + loadFromCache, + notificationChannel, + + // exports for testing purposes + mapCurrentState, + buildPostAuthorizationsRequest +}; \ No newline at end of file diff --git a/src/lib/model/common.js b/src/lib/model/common/BackendError.js similarity index 72% rename from src/lib/model/common.js rename to src/lib/model/common/BackendError.js index cf21c3ae3..8a5a6577b 100644 --- a/src/lib/model/common.js +++ b/src/lib/model/common/BackendError.js @@ -17,22 +17,10 @@ class BackendError extends Error { } toJSON() { - const ret = { - httpStatusCode: this.httpStatusCode - }; - - //copy across any other properties - for(let prop in this) { - if(this.hasOwnProperty(prop)) { - ret[prop] = this[prop]; - } - } - - return ret; + // return shallow clone of `this`, from `this` are only taken enumerable owned properties + return Object.assign({}, this); + } } - -module.exports = { - BackendError, -}; +module.exports = BackendError; diff --git a/src/lib/model/common/PersistentStateMachine.js b/src/lib/model/common/PersistentStateMachine.js new file mode 100644 index 000000000..f225e344f --- /dev/null +++ b/src/lib/model/common/PersistentStateMachine.js @@ -0,0 +1,94 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +'use strict'; +const StateMachine = require('javascript-state-machine'); + +async function saveToCache() { + const { data, cache, key, logger } = this.context; + try { + const res = await cache.set(key, data); + logger.push({ res }).log(`Persisted model in cache: ${key}`); + } + catch(err) { + logger.push({ err }).log(`Error saving model: ${key}`); + throw err; + } +} + +async function onAfterTransition(transition) { + const {data, logger} = this.context; + logger.log(`State machine transitioned '${transition.transition}': ${transition.from} -> ${transition.to}`); + data.currentState = transition.to; + await this.saveToCache(); +} + +function onPendingTransition(transition) { + // allow transitions to 'error' state while other transitions are in progress + if(transition !== 'error') { + throw new Error(`Transition requested while another transition is in progress: ${transition}`); + } +} + +async function create(data, cache, key, logger, stateMachineSpec ) { + let initState = stateMachineSpec.init || 'init'; + + if(!data.hasOwnProperty('currentState')) { + data.currentState = initState; + } else { + initState = stateMachineSpec.init = data.currentState; + } + + stateMachineSpec.data = Object.assign( + stateMachineSpec.data || {}, + { + context: { + data, cache, key, logger + } + } + ); + + stateMachineSpec.methods = Object.assign( + stateMachineSpec.methods || {}, + { + onAfterTransition, + onPendingTransition, + saveToCache + } + ); + + const stateMachine = new StateMachine(stateMachineSpec); + await stateMachine[initState]; + return stateMachine; +} + + +async function loadFromCache(cache, key, logger, stateMachineSpec, optCreate) { + try { + const data = await cache.get(key); + if(!data) { + throw new Error(`No cached data found for: ${key}`); + } + logger.push({ cache: data }).log('data loaded from cache'); + + // use delegation to allow customization of 'create' + const createPSM = optCreate || create; + return createPSM(data, cache, key, logger, stateMachineSpec); + } + catch(err) { + logger.push({ err }).log(`Error loading data: ${key}`); + throw err; + } +} + +module.exports = { + loadFromCache, + create +}; \ No newline at end of file diff --git a/src/lib/model/common/index.js b/src/lib/model/common/index.js new file mode 100644 index 000000000..3f90c1fdc --- /dev/null +++ b/src/lib/model/common/index.js @@ -0,0 +1,18 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +'use strict'; +const BackendError = require('./BackendError'); +const PersistentStateMachine = require('./PersistentStateMachine'); + +module.exports = { + BackendError, + PersistentStateMachine +}; diff --git a/src/lib/model/index.js b/src/lib/model/index.js index 925e4d5c7..b25911915 100644 --- a/src/lib/model/index.js +++ b/src/lib/model/index.js @@ -17,7 +17,8 @@ const OutboundRequestToPayTransferModel = require('./OutboundRequestToPayTransfe const AccountsModel = require('./AccountsModel'); const ProxyModel = require('./ProxyModel'); const OutboundRequestToPayModel = require('./OutboundRequestToPayModel'); -const { BackendError } = require('./common'); +const OutboundAuthorizationsModel = require('./OutboundAuthorizationsModel'); +const { BackendError, PersistentStateMachine } = require('./common'); module.exports = { @@ -27,5 +28,7 @@ module.exports = { AccountsModel, ProxyModel, BackendError, - OutboundRequestToPayModel + OutboundRequestToPayModel, + OutboundAuthorizationsModel, + PersistentStateMachine }; diff --git a/src/test/__mocks__/@mojaloop/sdk-standard-components.js b/src/test/__mocks__/@mojaloop/sdk-standard-components.js index f21135e55..f8eed42bb 100644 --- a/src/test/__mocks__/@mojaloop/sdk-standard-components.js +++ b/src/test/__mocks__/@mojaloop/sdk-standard-components.js @@ -26,6 +26,8 @@ class MockMojaloopRequests extends MojaloopRequests { this.putQuotesError = MockMojaloopRequests.__putQuotesError; this.getAuthorizations = MockMojaloopRequests.__getAuthorizations; this.putAuthorizations = MockMojaloopRequests.__putAuthorizations; + this.postAuthorizations = MockMojaloopRequests.__postAuthorizations; + this.getTransfers = MockMojaloopRequests.__getTransfers; this.putTransactionRequests = MockMojaloopRequests.__putTransactionRequests; this.postTransfers = MockMojaloopRequests.__postTransfers; @@ -41,6 +43,7 @@ MockMojaloopRequests.__putQuotes = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__putQuotesError = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__getAuthorizations = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__putAuthorizations = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__postAuthorizations = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__getTransfers = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__putTransactionRequests = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__postTransfers = jest.fn(() => Promise.resolve()); diff --git a/src/test/unit/inboundApi/handlers.test.js b/src/test/unit/inboundApi/handlers.test.js index 6ab9ebae3..b4e318b42 100644 --- a/src/test/unit/inboundApi/handlers.test.js +++ b/src/test/unit/inboundApi/handlers.test.js @@ -6,6 +6,7 @@ * * * ORIGINAL AUTHOR: * * Vassilis Barzokas - vassilis.barzokas@modusbox.com * + * Paweł Marzec - pawel.marzec@modusbox.com * **************************************************************************/ 'use strict'; @@ -16,18 +17,14 @@ const handlers = require('../../../InboundServer/handlers'); const Model = require('@internal/model').InboundTransfersModel; const mockArguments = require('./data/mockArguments'); const mockTransactionRequestData = require('./data/mockTransactionRequest'); -const { Logger, Transports } = require('@internal/log'); +const mockLogger = require('../mockLogger'); +const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; -let logTransports; describe('Inbound API handlers:', () => { let mockArgs; let mockTransactionRequest; - beforeAll(async () => { - logTransports = await Promise.all([Transports.consoleDir()]); - }); - beforeEach(() => { mockArgs = JSON.parse(JSON.stringify(mockArguments)); mockTransactionRequest = JSON.parse(JSON.stringify(mockTransactionRequestData)); @@ -49,7 +46,8 @@ describe('Inbound API handlers:', () => { response: {}, state: { conf: {}, - logger: new Logger({ context: { app: 'inbound-handlers-unit-test' }, space: 4, transports: logTransports }) + // example of elaborative logging with keepQuite = false + logger: mockLogger( { app: 'inbound-handlers-unit-test' }, false ) } }; @@ -84,7 +82,7 @@ describe('Inbound API handlers:', () => { response: {}, state: { conf: {}, - logger: new Logger({ context: { app: 'inbound-handlers-unit-test' }, space: 4, transports: logTransports }) + logger: mockLogger( { app: 'inbound-handlers-unit-test' } ) } }; }); @@ -120,7 +118,7 @@ describe('Inbound API handlers:', () => { 'ID': '1234' } }, - logger: new Logger({ context: { app: 'inbound-handlers-unit-test' }, space: 4, transports: logTransports }) + logger: mockLogger( { app: 'inbound-handlers-unit-test' } ) } }; }); @@ -134,4 +132,103 @@ describe('Inbound API handlers:', () => { expect(authorizationsSpy.mock.calls[0][1]).toBe(mockAuthorizationContext.request.headers['fspiop-source']); }); }); + + describe('PISP PUT /authorizations', () => { + + let mockAuthorizationContext; + beforeEach(() => { + + mockAuthorizationContext = { + request: { + headers: { + 'fspiop-source': 'foo' + }, + body: { + the: 'body' + } + }, + response: {}, + state: { + conf: { + enablePISPMode: true + }, + path : { + params : { + 'ID': '1234' + } + }, + logger: mockLogger( { app: 'inbound-handlers-unit-test' } ), + // there is no need to mock redis but only Cache + cache: { + publish: jest.fn(() => Promise.resolve()) + }, + } + }; + }); + + test('calls `model.authorizations` with the expected arguments.', async () => { + const notificationSpy = jest.spyOn(AuthorizationsModel, 'notificationChannel').mockImplementationOnce(() => 'notification-channel'); + + await expect(handlers['/authorizations/{ID}'].put(mockAuthorizationContext)).resolves.toBe(undefined); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + expect(notificationSpy).toHaveBeenCalledWith('1234'); + + const cache = mockAuthorizationContext.state.cache; + expect(cache.publish).toBeCalledTimes(1); + expect(cache.publish).toBeCalledWith('notification-channel', { + type: 'authorizationsResponse', + data: mockAuthorizationContext.request.body, + headers: mockAuthorizationContext.request.headers + }); + }); + }); + + describe('DFSP PUT /authorizations', () => { + + let mockAuthorizationContext; + beforeEach(() => { + + mockAuthorizationContext = { + request: { + headers: { + 'fspiop-source': 'foo' + }, + body: { + the: 'body' + } + }, + response: {}, + state: { + conf: { + enablePISPMode: false + }, + path : { + params : { + 'ID': '1234' + } + }, + logger: mockLogger( { app: 'inbound-handlers-unit-test' } ), + // there is no need to mock redis but only Cache + cache: { + publish: jest.fn(() => Promise.resolve()) + }, + } + }; + }); + + test('calls `model.authorizations` with the expected arguments.', async () => { + + await expect(handlers['/authorizations/{ID}'].put(mockAuthorizationContext)).resolves.toBe(undefined); + + const cache = mockAuthorizationContext.state.cache; + expect(cache.publish).toBeCalledTimes(1); + expect(cache.publish).toBeCalledWith('otp_1234', { + type: 'authorizationsResponse', + data: mockAuthorizationContext.request.body, + headers: mockAuthorizationContext.request.headers + }); + }); + }); + }); diff --git a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js new file mode 100644 index 000000000..bdd3b4af7 --- /dev/null +++ b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js @@ -0,0 +1,365 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +'use strict'; + +// we use a mock standard components lib to intercept and mock certain funcs +jest.mock('@mojaloop/sdk-standard-components'); + +const { uuid } = require('uuidv4'); +const Model = require('@internal/model').OutboundAuthorizationsModel; +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const defaultConfig = require('./data/defaultConfig'); +const mockLogger = require('../../mockLogger'); + + +describe('authorizationsModel', () => { + let cacheKey; + let data; + let modelConfig; + + const subId = 123; + let handler = null; + + afterEach(() => { + MojaloopRequests.__postAuthorizations = jest.fn(() => Promise.resolve()); + }); + + /** + * + * @param {Object} opts + * @param {Number} opts.expirySeconds + * @param {Object} opts.delays + * @param {Number} delays.requestQuotes + * @param {Number} delays.prepareTransfer + * @param {Object} opts.rejects + * @param {boolean} rejects.quoteResponse + * @param {boolean} rejects.transferFulfils + */ + + + beforeEach(async () => { + modelConfig = { + logger: mockLogger({app: 'OutboundAuthorizationsModel-test'}), + + // there is no need to mock redis but only Cache + cache: { + get: jest.fn(() => Promise.resolve(data)), + set: jest.fn(() => Promise.resolve), + + // mock subscription and store handler + subscribe: jest.fn(async (channel, h) => { + handler = jest.fn(h); + return subId; + }), + + // mock publish and call stored handler + publish: jest.fn(async (channel, message) => await handler(channel, message, subId)), + + unsubscribe: jest.fn(() => Promise.resolve()) + }, + ...defaultConfig + }; + data = {the: 'mocked data'}; + }); + + describe('create', () => { + test('proper creation of model', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + expect(model.state).toBe('start'); + + // model's methods layout + const methods = [ + 'run', 'getResponse', + 'onRequestAuthorization', 'onAuthorizationReceived' + ]; + + methods.forEach((method) => expect(typeof model[method]).toEqual('function')); + }); + }); + + describe('loadFromCache', () => { + it('should load properly', async () => { + modelConfig.cache.get = jest.fn(() => Promise.resolve({source: 'yes I came from the cache'})); + + const model = await Model.loadFromCache(cacheKey, modelConfig); + expect(model.context.data.source).toEqual('yes I came from the cache'); + expect(model.context.cache.get).toBeCalledTimes(1); + expect(model.context.cache.get).toBeCalledWith(cacheKey); + }); + }); + + describe('getResponse', () => { + + it('should remap currentState', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + const states = model.allStates(); + + // should remap for all states except 'init' and 'none' + states.filter((s) => s !== 'init' && s !== 'none').forEach((state) => { + model.context.data.currentState = state; + const result = model.getResponse(); + expect(result.currentState).toEqual(Model.mapCurrentState[state]); + }); + + }); + + it('should handle unexpected state', async() => { + const model = await Model.create(data, cacheKey, modelConfig); + + // simulate lack of state by undefined property + delete model.context.data.currentState; + + const resp = model.getResponse(); + expect(resp.currentState).toEqual(Model.mapCurrentState.errored); + + // ensure that we log the problem properly + expect(modelConfig.logger.log).toBeCalledWith(`Authorization model response being returned from an unexpected state: ${undefined}. Returning ERROR_OCCURRED state`); + }); + }); + + describe('notificationChannel', () => { + it('should validate input', () => { + const invalidIds = [ + null, + undefined, + '' + ]; + invalidIds.forEach((id) => { + const invocation = () => Model.notificationChannel(id); + expect(invocation).toThrow('OutboundAuthorizationsModel.notificationChannel: \'id\' parameter is required'); + }); + }); + + it('should generate proper channel name', () => { + const id = uuid(); + expect(Model.notificationChannel(id)).toEqual(`authorizations_${id}`); + }); + + }); + + describe('onAuthorizationReceived', () => { + it('should validate input', async () => { + const invalidMessages = [ + null, + undefined, + {}, + {body: null} + ]; + const model = await Model.create(data, cacheKey, modelConfig); + + const testCases = invalidMessages.map(async (msg) => { + expect(() => model.onAuthorizationReceived(msg)) + .rejects.ToEqual(new Error('OutboundAuthorizationsModel.onAuthorizationReceived: invalid \'message\' parameter is required')); + }); + + await Promise.allSettled(testCases); + }); + + it('should properly setup context.data', async () => { + const message = { + body: { + Iam: 'the-body' + } + }; + const model = await Model.create(data, cacheKey, modelConfig); + await model.onAuthorizationReceived(message); + + expect(model.context.data).toEqual(message.body); + }); + }); + + describe('onRequestAuthorization', () => { + + it('should implement happy flow', async () => { + data.transactionRequestId = uuid(); + const channel = Model.notificationChannel(data.transactionRequestId); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + // mock workflow execution which is tested in separate case + model.run = jest.fn(() => Promise.resolve()); + + // invoke transition handler + await model.onRequestAuthorization(); + + // subscribe should be called only once + expect(cache.subscribe).toBeCalledTimes(1); + + // subscribe should be done to proper notificationChannel + expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); + + // check invocation of request.postAuthorizations + expect(MojaloopRequests.__postAuthorizations).toBeCalledWith(Model.buildPostAuthorizationsRequest(data, modelConfig)); + + // ensure handler wasn't called before publishing the message + expect(handler).not.toBeCalled(); + + // ensure that cache.unsubscribe does not happened + expect(cache.unsubscribe).not.toBeCalled(); + + // fire publication to channel with given message + const message = { + body: { + Iam: 'the-body', + transactionRequestId: model.context.data.transactionRequestId + } + }; + await cache.publish(channel, message); + + // handler should be called only once + expect(handler).toBeCalledTimes(1); + + // the workflow should be run only once + expect(model.run).toBeCalledTimes(1); + expect(model.run).toBeCalledWith(message); + + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + + it('should unsubscribe from cache in case when error happens in workflow run', async () => { + data.transactionRequestId = uuid(); + const channel = Model.notificationChannel(data.transactionRequestId); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + // simulate error + model.run = jest.fn(() => Promise.reject('workflow failed')); + let theError = null; + try { + // invoke transition handler + await model.onRequestAuthorization(); + + // fire publication to channel with given message + const message = { + body: { + Iam: 'the-body', + transactionRequestId: data.transactionRequestId + } + }; + await cache.publish(channel, message); + + } catch(error) { + theError = error; + } + expect(theError).toEqual('workflow failed'); + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + + it('should unsubscribe from cache in case when error happens Mojaloop requests', async () => { + // simulate error + MojaloopRequests.__postAuthorizations = jest.fn(() => Promise.reject('postAuthorization failed')); + data.transactionRequestId = uuid(); + + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + let theError = null; + // invoke transition handler + try { + await model.onRequestAuthorization(); + throw new Error('this point should not be reached'); + } catch (error) { + theError = error; + } + expect(theError).toEqual('postAuthorization failed'); + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + + }); + + describe('run workflow', () => { + it('start', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.requestAuthorization = jest.fn(); + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'start'; + const result = await model.run(); + expect(result).toEqual({the: 'response'}); + expect(model.requestAuthorization).toBeCalledTimes(1); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log).toBeCalledWith(`Authorization requested for ${model.context.data.transactionRequestId}`); + }); + + it('waitingForAuthorization', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.authorizationReceived = jest.fn(); + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'waitingForAuthorization'; + const result = await model.run({the: 'message'}); + + expect(result).toEqual({the: 'response'}); + expect(model.authorizationReceived).toBeCalledTimes(1); + expect(model.authorizationReceived).toBeCalledWith({the: 'message'}); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log).toBeCalledWith(`Authorization received for ${model.context.data.transactionRequestId}`); + }); + + it('succeeded', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'succeeded'; + const result = await model.run({the: 'message'}); + + expect(result).toEqual({the: 'response'}); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log).toBeCalledWith('Authorization completed successfully'); + }); + + it('errored', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'errored'; + const result = await model.run({the: 'message'}); + + expect(result).toBeFalsy(); + expect(model.getResponse).not.toBeCalled(); + expect(model.context.logger.log).toBeCalledWith('State machine in errored state'); + }); + + it('should handle errors', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.requestAuthorization = jest.fn(() => { + const err = new Error('requestAuthorization failed'); + err.authorizationState = 'some'; + return Promise.reject(err); + }); + model.error = jest.fn(); + model.context.data.currentState = 'start'; + + let theError = null; + try { + await model.run(); + throw new Error('this point should not be reached'); + } catch(error) { + theError = error; + } + // check propagation of original error + expect(theError.message).toEqual('requestAuthorization failed'); + + // ensure we start transition to errored state + expect(model.error).toBeCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/src/test/unit/lib/model/common/PersistentStateMachine.test.js b/src/test/unit/lib/model/common/PersistentStateMachine.test.js new file mode 100644 index 000000000..d63b5ea86 --- /dev/null +++ b/src/test/unit/lib/model/common/PersistentStateMachine.test.js @@ -0,0 +1,202 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +'use strict'; + +const Cache = jest.createMockFromModule('@internal/cache'); + +const PSM = require('@internal/model').PersistentStateMachine; +const mockLogger = require('../../../mockLogger'); +describe('PersistentStateMachine', () => { + let cache; + let data; + let smSpec; + const key = 'cache-key'; + + const logger = mockLogger({app: 'persistent-state-machine-test'}); + + function checkPSMLayout(psm, optData) { + expect(psm).toBeTruthy(); + + expect(psm.state).toEqual((optData && optData.currentState) || smSpec.init || 'none'); + + expect(psm.context).toEqual({ + // allow passing optional data, elsewhere use default + data: optData || data, + cache, + key, + logger + }); + + expect(typeof psm.onAfterTransition).toEqual('function'); + expect(typeof psm.onPendingTransition).toEqual('function'); + expect(typeof psm.saveToCache).toEqual('function'); + expect(typeof psm.init).toEqual('function'); + expect(typeof psm.gogo).toEqual('function'); + expect(typeof psm.error).toEqual('function'); + } + + function shouldNotBeExecuted() { + throw new Error('test failure enforced: this code should never be executed'); + } + + beforeEach(async () => { + smSpec = { + // init: 'start', + transitions: [ + { name: 'init', from: 'none', to: 'start'}, + { name: 'gogo', from: 'start', to: 'end' }, + { name: 'error', from: '*', to: 'errored' } + ], + methods: { + onGogo: async () => { + return new Promise( (resolved) => { + setTimeout((() => resolved(true)), 100); + } ); + }, + onError: () => { + console.error('onError'); + } + } + }; + + // test data + data = { the: 'data' }; + + cache = new Cache({ + host: 'dummycachehost', + port: 1234, + logger, + }); + // mock cache set & get + cache.get = jest.fn(async () => data); + cache.set = jest.fn(async () => 'cache set replies'); + + await cache.connect(); + }); + + afterEach(async () => { + await cache.disconnect(); + }); + + test('module layout', () => { + expect(typeof PSM.create).toEqual('function'); + expect(typeof PSM.loadFromCache).toEqual('function'); + }); + + test('create', async () => { + const psm = await PSM.create(data, cache, key, logger, smSpec); + checkPSMLayout(psm); + expect(psm.state).toEqual('none'); + await psm.init(); + expect(psm.state).toEqual('start'); + }); + + describe('onPendingTransition', () => { + it('should throw error if not `error` transition', async () => { + const psm = await PSM.create(data, cache, key, logger, smSpec); + checkPSMLayout(psm); + + psm.init(); + expect(() => psm.gogo()).toThrowError('Transition requested while another transition is in progress: gogo'); + + }); + + it('should not throw error if `error` transition called when `gogo` is pending', (done) => { + PSM.create(data, cache, key, logger, smSpec).then((psm) => { + checkPSMLayout(psm); + + psm.init() + .then(() => { + expect(psm.state).toEqual('start'); + psm.gogo(); + expect(psm.state).toEqual('end'); + return Promise.resolve(); + }) + .then(() => psm.error()) + .then(done) + .catch(shouldNotBeExecuted); + }); + }); + }); + + describe('loadFromCache', () => { + it('should properly call cache.get, get expected data in `context.data` and setup state of machine', async () => { + const dataFromCache = { this_is: 'data from cache', currentState: 'end'}; + cache.get = jest.fn( async () => dataFromCache); + const psm = await PSM.loadFromCache(cache, key, logger, smSpec); + checkPSMLayout(psm, dataFromCache); + + // to get value from cache proper key should be used + expect(cache.get).toHaveBeenCalledWith(key); + + // check what has been stored in `context.data` + expect(psm.context.data).toEqual(dataFromCache); + + }); + + it('should throw when received invalid data from `cache.get`', async () => { + cache.get = jest.fn( async () => null); + try { + await PSM.loadFromCache(cache, key, logger, smSpec); + shouldNotBeExecuted(); + } catch (error) { + expect(error.message).toEqual(`No cached data found for: ${key}`); + } + }); + + it('should propagate error received from `cache.get`', async () => { + cache.get = jest.fn( async () => { throw new Error('error from cache.get'); }); + expect(() => PSM.loadFromCache(cache, key, logger, smSpec)) + .rejects.toEqual(new Error('error from cache.get')); + }); + }); + + describe('saveToCache', () => { + it('should be called after transition', async () => { + const psm = await PSM.create(data, cache, key, logger, smSpec); + checkPSMLayout(psm); + + // make transition from none -> start + await psm.init(); + + // check state + expect(psm.state).toEqual('start'); + + // check state propagation to data + expect(psm.context.data.currentState).toEqual('start'); + + // make transition from start -> end + await psm.gogo(); + + // check state change + expect(psm.state).toEqual('end'); + + // check what has been stored in cache + expect(cache.set).toBeCalledWith(key, psm.context.data); + + // check state propagation to `context.data` + expect(psm.context.data.currentState).toEqual('end'); + }); + + it('should rethrow error from cache.set', async () => { + + // mock to simulate throwing error + cache.set = jest.fn(() => { throw new Error('error from cache.set'); }); + + const psm = await PSM.create(data, cache, key, logger, smSpec); + checkPSMLayout(psm); + + // transition `init` should encounter exception when saving `context.data` + expect(() => psm.init()).rejects.toEqual(new Error('error from cache.set')); + }); + }); + +}); \ No newline at end of file diff --git a/src/test/unit/mockLogger.js b/src/test/unit/mockLogger.js new file mode 100644 index 000000000..0cbb05a9f --- /dev/null +++ b/src/test/unit/mockLogger.js @@ -0,0 +1,29 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +const { Logger, Transports } = require('@internal/log'); + +function mockLogger(context, keepQuiet) { + // if keepQuite is undefined then be quiet + if(keepQuiet || typeof keepQuiet === 'undefined') { + const log = { + log: jest.fn() + }; + return { + ...log, + push: jest.fn(() => log) + }; + } + // let be elaborative and dir logging to console + const consoleTransport = Transports.consoleDir(); + return new Logger({ context: context, space: 4, transports: [ consoleTransport ] }); +} + +module.exports = mockLogger; \ No newline at end of file diff --git a/src/test/unit/outboundApi/handlers.test.js b/src/test/unit/outboundApi/handlers.test.js index 48a7f9fed..76d2c565a 100644 --- a/src/test/unit/outboundApi/handlers.test.js +++ b/src/test/unit/outboundApi/handlers.test.js @@ -16,11 +16,12 @@ const mockRequestToPayTransferError = require('./data/mockRequestToPayTransferEr const transferRequest = require('./data/transferRequest'); const requestToPayPayload = require('./data/requestToPay'); const requestToPayTransferRequest = require('./data/requestToPayTransferRequest'); +const mockLogger = require('../mockLogger'); jest.mock('@internal/model'); const handlers = require('../../../OutboundServer/handlers'); -const { OutboundTransfersModel, OutboundRequestToPayTransferModel, OutboundRequestToPayModel } = require('@internal/model'); +const { OutboundTransfersModel, OutboundRequestToPayTransferModel, OutboundRequestToPayModel, OutboundAuthorizationsModel } = require('@internal/model'); /** * Mock the outbound transfer model to simulate throwing errors @@ -96,7 +97,7 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; @@ -124,7 +125,7 @@ describe('Outbound API handlers:', () => { conf: { outboundErrorStatusCodeExtensionKey: 'extErrorKey' // <- tell the handler to use this extensionList item as source of statusCode }, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; @@ -154,7 +155,7 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; @@ -184,7 +185,7 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: console, + logger: mockLogger({ app: 'outbound-api-handlers-test'}), path: { params: { transferId: '12345' @@ -216,7 +217,7 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; @@ -231,4 +232,53 @@ describe('Outbound API handlers:', () => { }); }); + describe('POST /authorizations', () => { + test('happy flow', async() => { + + const mockContext = { + request: { + body: {the: 'body'}, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + wso2Auth: 'mocked wso2Auth', + logger: mockLogger({ app: 'outbound-api-handlers-test'}), + cache: { the: 'mocked cache' } + }, + }; + + // mock state machine + const mockedPSM = { + run: jest.fn(async () => ({ the: 'run response' })) + }; + + const createSpy = jest.spyOn(OutboundAuthorizationsModel, 'create') + .mockImplementationOnce(async () => mockedPSM); + + // invoke handler + await handlers['/authorizations'].post(mockContext); + + // PSM model creation + expect(createSpy).toBeCalledTimes(1); + const request = mockContext.request; + const state = mockContext.state; + expect(createSpy).toBeCalledWith(request.body, { + cache: state.cache, + logger: state.logger, + wso2Auth: state.wso2Auth + }); + + // run workflow + expect(mockedPSM.run).toBeCalledTimes(1); + expect(mockedPSM.run.mock.calls[0].length).toBe(0); + + // response + expect(mockContext.response.status).toBe(200); + expect(mockContext.response.body).toEqual({ the: 'run response' }); + }); + }); }); From 3085167621a33d495a4fdc69cc88e9ada4dbb8dd Mon Sep 17 00:00:00 2001 From: sridharvoruganti <36686863+sridharvoruganti@users.noreply.github.com> Date: Thu, 9 Jul 2020 13:24:47 +0530 Subject: [PATCH 08/29] #321 Handle transfer calls (Switch --> PISP) (#175) * #321 Handle transfer calls (Switch --> PISP) * #321fixed lint issue * #321 review comments implemented * #321 addressed new review comments(code formatted) * #321 repetitive code moved to deepClone() --- src/InboundServer/api.yaml | 258 +++++++++++++++++- src/InboundServer/handlers.js | 106 ++++++- .../InboundThirdpartyTransactionModel.js | 99 +++++++ .../OutboundThirdpartyTransactionModel.js | 28 ++ src/lib/model/index.js | 6 +- src/lib/model/lib/requests/backendRequests.js | 9 + src/lib/model/lib/shared/index.js | 34 ++- src/test/__mocks__/@internal/requests.js | 2 + .../api/transfers/data/putPartiesBody.json | 6 +- src/test/unit/data/putPartiesBody.json | 6 +- src/test/unit/inboundApi/handlers.test.js | 151 +++++++--- .../InboundThirdpartyTransactionModel.test.js | 63 +++++ .../data/mockAuthorizationArguments.json | 60 ++++ 13 files changed, 785 insertions(+), 43 deletions(-) create mode 100644 src/lib/model/InboundThirdpartyTransactionModel.js create mode 100644 src/lib/model/OutboundThirdpartyTransactionModel.js create mode 100644 src/test/unit/lib/model/InboundThirdpartyTransactionModel.test.js create mode 100644 src/test/unit/lib/model/data/mockAuthorizationArguments.json diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index 6bb9f490a..95e2be9e2 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -1424,6 +1424,54 @@ paths: schema: $ref: '#/components/schemas/QuotesPostRequest' required: true +#Authorizations + /authorizations: + post: + description: > + The HTTP request `POST /authorizations` is used to request the Payer to enter the + applicable credentials in the PISP system. + summary: Perform PISP authorization + tags: + - authorizations + operationId: AuthorizationsByIDPost + parameters: + #Headers + - $ref: '#/components/parameters/Content-Length' + - $ref: '#/components/parameters/Content-Type' + - $ref: '#/components/parameters/Date' + - $ref: '#/components/parameters/X-Forwarded-For' + - $ref: '#/components/parameters/FSPIOP-Source' + - $ref: '#/components/parameters/FSPIOP-Destination' + - $ref: '#/components/parameters/FSPIOP-Encryption' + - $ref: '#/components/parameters/FSPIOP-Signature' + - $ref: '#/components/parameters/FSPIOP-URI' + - $ref: '#/components/parameters/FSPIOP-HTTP-Method' + requestBody: + description: Perform authorization + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizationsPostRequest' + responses: + 202: + $ref: '#/components/responses/Response202' + 400: + $ref: '#/components/responses/ErrorResponse400' + 401: + $ref: '#/components/responses/ErrorResponse401' + 403: + $ref: '#/components/responses/ErrorResponse403' + 404: + $ref: '#/components/responses/ErrorResponse404' + 405: + $ref: '#/components/responses/ErrorResponse405' + 406: + $ref: '#/components/responses/ErrorResponse406' + 501: + $ref: '#/components/responses/ErrorResponse501' + 503: + $ref: '#/components/responses/ErrorResponse503' '/authorizations/{ID}': parameters: - $ref: '#/components/parameters/ID' @@ -1495,8 +1543,10 @@ paths: x-examples: application/json: authenticationInfo: - authentication: OTP - authenticationValue: '1234' + authentication: U2F + authenticationValue: + pinValue: '233133331' + counter: '1' responseType: ENTERED responses: '200': @@ -2503,6 +2553,108 @@ paths: $ref: '#/components/responses/ErrorResponse503' requestBody: $ref: '#/components/requestBodies/ErrorInformationObject' + #thirdPartyRequests + /thirdPartyRequests/transactions/{ID}: + put: + description: > + The callback `PUT /thirdPartyRequests/transactions/{ID}` is used to inform the client of the result of a + previously-requested transaction. The `{ID}` in the URI should contain the `transactionRequestId` that was used for + the creation of the transaction request. + summary: Update third party transaction requests + tags: + - thirdPartyRequests + operationId: UpdateThirdPartyTransactionRequests + parameters: + #Path + - $ref: '#/components/parameters/ID' + #Headers + - $ref: '#/components/parameters/Content-Type' + - $ref: '#/components/parameters/Date' + - $ref: '#/components/parameters/X-Forwarded-For' + - $ref: '#/components/parameters/FSPIOP-Source' + - $ref: '#/components/parameters/FSPIOP-Destination' + - $ref: '#/components/parameters/FSPIOP-Encryption' + - $ref: '#/components/parameters/FSPIOP-Signature' + - $ref: '#/components/parameters/FSPIOP-URI' + - $ref: '#/components/parameters/FSPIOP-HTTP-Method' + - $ref: '#/components/parameters/Content-Length' + requestBody: + description: Transaction request result returned. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ThirdPartyTransactionResponse' + responses: + 200: + $ref: '#/components/responses/Response200' + 400: + $ref: '#/components/responses/ErrorResponse400' + 401: + $ref: '#/components/responses/ErrorResponse401' + 403: + $ref: '#/components/responses/ErrorResponse403' + 404: + $ref: '#/components/responses/ErrorResponse404' + 405: + $ref: '#/components/responses/ErrorResponse405' + 406: + $ref: '#/components/responses/ErrorResponse406' + 501: + $ref: '#/components/responses/ErrorResponse501' + 503: + $ref: '#/components/responses/ErrorResponse503' + /thirdPartyRequest/transactions/{ID}/error: + put: + description: > + If the server is unable to find the transaction request, or another processing error occurs, + the error callback `PUT /thirdPartyRequest/transactions/{ID}/error` is used. + The `{ID}` in the URI should contain the `transactionRequestId` that was used for the creation of the transaction request. + summary: Return transaction error + tags: + - thirdPartyRequests + operationId: UpdateThirdPartyTransactionRequestsError + parameters: + #Path + - $ref: '#/components/parameters/ID' + #Headers + - $ref: '#/components/parameters/Content-Length' + - $ref: '#/components/parameters/Content-Type' + - $ref: '#/components/parameters/Date' + - $ref: '#/components/parameters/X-Forwarded-For' + - $ref: '#/components/parameters/FSPIOP-Source' + - $ref: '#/components/parameters/FSPIOP-Destination' + - $ref: '#/components/parameters/FSPIOP-Encryption' + - $ref: '#/components/parameters/FSPIOP-Signature' + - $ref: '#/components/parameters/FSPIOP-URI' + - $ref: '#/components/parameters/FSPIOP-HTTP-Method' + requestBody: + description: Details of the error returned. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorInformationObject' + responses: + 200: + $ref: '#/components/responses/Response200' + 400: + $ref: '#/components/responses/ErrorResponse400' + 401: + $ref: '#/components/responses/ErrorResponse401' + 403: + $ref: '#/components/responses/ErrorResponse403' + 404: + $ref: '#/components/responses/ErrorResponse404' + 405: + $ref: '#/components/responses/ErrorResponse405' + 406: + $ref: '#/components/responses/ErrorResponse406' + 501: + $ref: '#/components/responses/ErrorResponse501' + 503: + $ref: '#/components/responses/ErrorResponse503' + components: parameters: Accept: @@ -2896,6 +3048,15 @@ components: minLength: 1 maxLength: 64 description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. + Counter: + title: Counter + $ref: '#/components/schemas/Integer' + description: Sequential counter used for cloning detection. Present only for U2F authentication. + RetriesLeft: + title: RetriesLeft + $ref: '#/components/schemas/Integer' + description: RetriesLeft is the number of retries left before the financial transaction is rejected. It must be expressed in the form of the data type Integer. retriesLeft=1 means that this is the last retry before the financial transaction is rejected. + AuthorizationResponse: title: AuthorizationResponse type: string @@ -3545,6 +3706,36 @@ components: required: - authentication - authenticationValue + AuthorizationsPostRequest: + title: AuthorizationsPostRequest + type: object + description: POST /authorizations Request object + properties: + authenticationType: + $ref: '#/components/schemas/AuthenticationType' + description: This value is a valid authentication type from the enumeration AuthenticationType(OTP or QR Code or U2F). + retriesLeft: + $ref: '#/components/schemas/RetriesLeft' + description: RetriesLeft is the number of retries left before the financial transaction is rejected. It must be expressed in the form of the data type Integer. retriesLeft=1 means that this is the last retry before the financial transaction is rejected. + amount: + $ref: '#/components/schemas/Money' + description: This is the transaction amount that will be withdrawn from the Payer’s account. + transactionId: + $ref: '#/components/schemas/CorrelationId' + description: Common ID (decided by the Payer FSP) between the FSPs for the future transaction object. The actual transaction will be created as part of a successful transfer process. + transactionRequestId: + $ref: '#/components/schemas/CorrelationId' + description: The transactionRequestID, received from the POST /transactionRequests service earlier in the process. + quote: + $ref: '#/components/schemas/QuotesIDPutResponse' + description: Quotes object + required: + - authenticationType + - retriesLeft + - amount + - transactionId + - transactionRequestId + - quote AuthorizationsIDPutResponse: title: AuthorizationsIDPutResponse type: object @@ -3996,6 +4187,9 @@ components: type: object description: Data model for the complex type Party. properties: + accounts: + $ref: '#/components/schemas/AccountList' + description: List of accounts associated with the party containing and DFSP routable address, currency identifier and description. partyIdInfo: $ref: '#/components/schemas/PartyIdInfo' description: 'Party Id type, id, sub ID or type, and FSP Id.' @@ -4455,3 +4649,63 @@ components: description: 'Optional extension, specific to deployment.' required: - transferState + ThirdPartyTransactionResponse: + title: ThirdPartyTransactionResponse + type: object + description: The object sent in the PUT /thirdPartyRequests/transactions/{ID} request. + properties: + transactionId: + $ref: '#/components/schemas/CorrelationId' + description: > + Identifies a related transaction (if a transaction has been created) + transactionRequestState: + $ref: '#/components/schemas/TransactionRequestState' + description: State of the transaction request` + AccountId: + type: string + description: > + A long-lived account identifier provided by the DFSP + this MUST NOT be Bank Account Number or anything that + may expose a User's private bank account information + AccountList: + title: AccountList + type: object + description: Data model for the complex type AccountList + properties: + account: + type: array + items: + $ref: '#/components/schemas/Account' + minItems: 1 + maxItems: 32 + description: Accounts associated with the Party + required: + - account + Account: + title: Account + type: object + description: Data model for the complex type Account + properties: + address: + $ref: '#/components/schemas/AccountAddress' + type: string + description: Unique routable address which is DFSP specific. + currency: + $ref: '#/components/schemas/Currency' + type: string + description: Currency of the amount. + description: + $ref: '#/components/schemas/Name' + type: string + description: The name of the account. + required: + - address + - currency + - description + AccountAddress: + title: AccountAddress + type: string + description: Unique routable address which is DFSP specific. + pattern: ^([0-9A-Za-z_~\-\.]+[0-9A-Za-z_~\-])$ + minLength: 1 + maxLength: 1023 \ No newline at end of file diff --git a/src/InboundServer/handlers.js b/src/InboundServer/handlers.js index 75ed94e3b..9d302dd86 100644 --- a/src/InboundServer/handlers.js +++ b/src/InboundServer/handlers.js @@ -7,6 +7,7 @@ * ORIGINAL AUTHOR: * * James Bush - james.bush@modusbox.com * * Paweł Marzec - pawel.marzec@modusbox.com * + * Sridhar Voruganti - sridhar.voruganti@modusbox.com * **************************************************************************/ 'use strict'; @@ -14,6 +15,8 @@ const util = require('util'); const Model = require('@internal/model').InboundTransfersModel; const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; +const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; +const ThirdpartyTrxnModelOut = require('@internal/model').OutboundThirdpartyTransactionModel; /** * Handles a GET /authorizations/{id} request */ @@ -58,6 +61,50 @@ const getAuthorizationsById = async (ctx) => { ctx.response.body = ''; }; +/** + * Handles POST /authorizations request + */ +const postAuthorizations = async (ctx) => { + // kick off an asyncronous operation to handle the request + (async () => { + try { + if (ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`request_${ctx.request.body.transactionRequestId}`, req); + ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); + } + // use the transfers model to execute asynchronous stages with the switch + const thirdpartyTrxnModelIn = new ThirdpartyTrxnModelIn({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + const sourceFspId = ctx.request.headers['fspiop-source']; + + // use the model to handle the request + const response = await thirdpartyTrxnModelIn.postAuthorizations(ctx.request.body, sourceFspId); + + // log the result + ctx.state.logger.push({ response }).log('Inbound Third party transaction model handled POST /authorizations request'); + } + catch (err) { + // nothing we can do if an error gets thrown back to us here apart from log it and continue + ctx.state.logger.push({ err }).log('Error handling POST /authorizations}'); + } + })(); + + // Note that we will have passed request validation, JWS etc... by this point + // so it is safe to return 202 + ctx.response.status = 202; + ctx.response.body = ''; +}; + /** * Handles a GET /participants/{idType}/{idValue} request */ @@ -600,17 +647,68 @@ const putTransfersByIdError = async (ctx) => { ctx.response.body = ''; }; +/** + * Handles PUT /thirdPartyRequests/transactions/{ID} request. + * This is response to a POST /thirdPartyRequests/transactions request + */ +const putThirdPartyReqTransactionsById = async (ctx) => { + if (ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); + } + + // publish an event onto the cache for subscribers to action + await ThirdpartyTrxnModelOut.publishNotifications(ctx.state.cache, ctx.state.path.params.ID, { + type: 'thirdPartyTransactionsReqResponse', + data: ctx.request.body, + headers: ctx.request.headers + }); + + ctx.response.status = 200; +}; + +/** + * Handles PUT /thirdPartyRequests/transactions/{ID}/error. + * This is error response to POST /thirdPartyRequests/transactions request + */ +const putThirdPartyReqTransactionsByIdError = async (ctx) => { + if (ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); + } + + // publish an event onto the cache for subscribers to action + await ThirdpartyTrxnModelOut.publishNotifications(ctx.state.cache, ctx.state.path.params.ID, { + type: 'thirdPartyTransactionsReqErrorResponse', + data: ctx.request.body, + headers: ctx.request.headers + }); + + ctx.response.status = 200; +}; const healthCheck = async(ctx) => { ctx.response.status = 200; ctx.response.body = ''; }; - module.exports = { '/': { get: healthCheck }, + '/authorizations': { + post: postAuthorizations + }, '/authorizations/{ID}': { get: getAuthorizationsById, put: putAuthorizationsById @@ -669,5 +767,11 @@ module.exports = { }, '/transactionRequests/{ID}': { put: putTransactionRequestsById + }, + '/thirdPartyRequests/transactions/{ID}': { + put: putThirdPartyReqTransactionsById + }, + '/thirdPartyRequests/transactions/{ID}/error': { + put: putThirdPartyReqTransactionsByIdError } }; diff --git a/src/lib/model/InboundThirdpartyTransactionModel.js b/src/lib/model/InboundThirdpartyTransactionModel.js new file mode 100644 index 000000000..0012b5c9a --- /dev/null +++ b/src/lib/model/InboundThirdpartyTransactionModel.js @@ -0,0 +1,99 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Sridhar Voruganti - sridhar.voruganti@modusbox.com * + **************************************************************************/ + +'use strict'; + +const { + BackendRequests, + HTTPResponseError, +} = require('@internal/requests'); + +const { + MojaloopRequests, + Errors, +} = require('@mojaloop/sdk-standard-components'); + +const shared = require('@internal/shared'); + +/** + * Models the operations required for inbound third party transaction requests + */ +class InboundThirdpartyTransactionModel { + constructor(config) { + this._logger = config.logger; + this._dfspId = config.dfspId; + + this._mojaloopRequests = new MojaloopRequests({ + logger: this._logger, + peerEndpoint: config.peerEndpoint, + alsEndpoint: config.alsEndpoint, + quotesEndpoint: config.quotesEndpoint, + transfersEndpoint: config.transfersEndpoint, + dfspId: config.dfspId, + tls: config.tls, + jwsSign: config.jwsSign, + jwsSigningKey: config.jwsSigningKey, + wso2Auth: config.wso2Auth + }); + + this._backendRequests = new BackendRequests({ + logger: this._logger, + backendEndpoint: config.backendEndpoint, + dfspId: config.dfspId + }); + } + + /** + * Queries the backend API to get the authorization details and makes a + * callback to the originator with the result + */ + async postAuthorizations(authorizationsReq, sourceFspId) { + try { + //Converts a mojaloop authorizationsReq data to internal form + const internalForm = shared.mojaloopAuthorizationsReqToInternal(authorizationsReq); + + // make a call to the backend to ask for a authorizations response + const response = await this._backendRequests.getSignedChallenge(internalForm); + if (!response) { + // make an error callback to the source fsp + return 'No response from backend'; + } + // project our internal authorizations reponse into mojaloop response form + const mojaloopResponse = shared.internalAuthorizationsResponseToMojaloop(response); + // make a callback to the source fsp with the party info + return this._mojaloopRequests.putAuthorizations(authorizationsReq.transactionRequestId, mojaloopResponse, sourceFspId); + } + catch (err) { + this._logger.push({ err }).log('Error in postAuthorizations'); + const mojaloopError = await this._handleError(err); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return await this._mojaloopRequests.putAuthorizationsError(authorizationsReq.transactionRequestId, mojaloopError, sourceFspId); + } + } + + async _handleError(err) { + let mojaloopErrorCode = Errors.MojaloopApiErrorCodes.INTERNAL_SERVER_ERROR; + if (err instanceof HTTPResponseError) { + const e = err.getData(); + if (e.res && e.res.body) { + try { + const bodyObj = JSON.parse(e.res.body); + mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${bodyObj.statusCode}`); + } + catch (ex) { + this._logger.push({ ex }).log('Error parsing error message body as JSON'); + } + } + } + return new Errors.MojaloopFSPIOPError(err, null, null, mojaloopErrorCode).toApiErrorObject(); + } +} + +module.exports = InboundThirdpartyTransactionModel; diff --git a/src/lib/model/OutboundThirdpartyTransactionModel.js b/src/lib/model/OutboundThirdpartyTransactionModel.js new file mode 100644 index 000000000..bb34f6aa6 --- /dev/null +++ b/src/lib/model/OutboundThirdpartyTransactionModel.js @@ -0,0 +1,28 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Sridhar Voruganti - sridhar.voruganti@modusbox.com * + **************************************************************************/ + +'use strict'; + +function notificationChannel(id) { + // mvp validation + if (!(id && id.toString().length > 0)) { + throw new Error('OutboundThirdpartyTransactionModel.notificationChannel: \'id\' parameter is required'); + } + + // channel name + return `3ptrxnreq_${id}`; +} + +async function publishNotifications(cache, id, value) { + const channel = notificationChannel(id); + return cache.publish(channel, value); +} + +module.exports = { notificationChannel, publishNotifications }; \ No newline at end of file diff --git a/src/lib/model/index.js b/src/lib/model/index.js index b25911915..a04caa8fc 100644 --- a/src/lib/model/index.js +++ b/src/lib/model/index.js @@ -18,6 +18,8 @@ const AccountsModel = require('./AccountsModel'); const ProxyModel = require('./ProxyModel'); const OutboundRequestToPayModel = require('./OutboundRequestToPayModel'); const OutboundAuthorizationsModel = require('./OutboundAuthorizationsModel'); +const InboundThirdpartyTransactionModel = require('./InboundThirdpartyTransactionModel'); +const OutboundThirdpartyTransactionModel = require('./OutboundThirdpartyTransactionModel'); const { BackendError, PersistentStateMachine } = require('./common'); @@ -30,5 +32,7 @@ module.exports = { BackendError, OutboundRequestToPayModel, OutboundAuthorizationsModel, - PersistentStateMachine + PersistentStateMachine, + InboundThirdpartyTransactionModel, + OutboundThirdpartyTransactionModel }; diff --git a/src/lib/model/lib/requests/backendRequests.js b/src/lib/model/lib/requests/backendRequests.js index 3311bbf7a..ba5240eec 100644 --- a/src/lib/model/lib/requests/backendRequests.js +++ b/src/lib/model/lib/requests/backendRequests.js @@ -37,6 +37,15 @@ class BackendRequests { this.backendEndpoint = `${this.transportScheme}://${config.backendEndpoint}`; } + /** + * Executes a /signchallenge request by passing authorization request details + * + * @returns {object} - JSON response body if one was received + */ + async getSignedChallenge(authorizationReq) { + return this._post('signchallenge', authorizationReq); + } + /** * Executes a GET /otp request for the specified transaction request id * diff --git a/src/lib/model/lib/shared/index.js b/src/lib/model/lib/shared/index.js index d03d8314b..3993ed7ff 100644 --- a/src/lib/model/lib/shared/index.js +++ b/src/lib/model/lib/shared/index.js @@ -285,6 +285,36 @@ const mojaloopTransactionRequestToInternal = (external) => { return internal; }; +/** + * Converts a mojaloop authorizationsReq data to internal form + * + * @returns {object} + */ +const mojaloopAuthorizationsReqToInternal = (external) => { + let internal = external; + //TODO: conversion required ? + return internal; +}; + +/** + * Converts an internal authorizations response to mojaloop form + * + * @returns {object} + */ +const internalAuthorizationsResponseToMojaloop = (internal) => { + //TODO: conversion required or not ? + const external = { + authenticationInfo: { + authentication: 'U2F', + authenticationValue: { + pinValue: internal.pinValue, + counter: internal.counter + } + }, + responseType: 'ENTERED' + }; + return external; +}; module.exports = { internalPartyToMojaloopParty, @@ -294,5 +324,7 @@ module.exports = { mojaloopPartyIdInfoToInternalPartyIdInfo, mojaloopQuoteRequestToInternal, mojaloopPrepareToInternalTransfer, - mojaloopTransactionRequestToInternal + mojaloopTransactionRequestToInternal, + mojaloopAuthorizationsReqToInternal, + internalAuthorizationsResponseToMojaloop }; diff --git a/src/test/__mocks__/@internal/requests.js b/src/test/__mocks__/@internal/requests.js index 64dad53ea..bed9af352 100644 --- a/src/test/__mocks__/@internal/requests.js +++ b/src/test/__mocks__/@internal/requests.js @@ -23,6 +23,7 @@ class MockBackendRequests extends BackendRequests { super(...args); MockBackendRequests.__instance = this; this.getOTP = MockBackendRequests.__getOTP; + this.getSignedChallenge = MockBackendRequests.__getSignedChallenge; this.getParties = MockBackendRequests.__getParties; this.postTransactionRequests = MockBackendRequests.__postTransactionRequests; this.postQuoteRequests = MockBackendRequests.__postQuoteRequests; @@ -32,6 +33,7 @@ class MockBackendRequests extends BackendRequests { } MockBackendRequests.__getParties = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__getOTP = jest.fn(() => Promise.resolve({body: {}})); +MockBackendRequests.__getSignedChallenge = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__postTransactionRequests = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__postQuoteRequests = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__getTransfers = jest.fn(() => Promise.resolve({body: {}})); diff --git a/src/test/unit/api/transfers/data/putPartiesBody.json b/src/test/unit/api/transfers/data/putPartiesBody.json index 67faec305..817843bd0 100644 --- a/src/test/unit/api/transfers/data/putPartiesBody.json +++ b/src/test/unit/api/transfers/data/putPartiesBody.json @@ -3,8 +3,8 @@ "accounts": { "account": [ { - "”currency": "USD", - "”description": "savings", + "currency": "USD", + "description": "savings", "address": "moja.red.8f027046-b82a5456-4fa9-838b-70210fcf8136" }, { @@ -31,4 +31,4 @@ "name": "John Doe", "merchantClassificationCode": "1234" } -} +} \ No newline at end of file diff --git a/src/test/unit/data/putPartiesBody.json b/src/test/unit/data/putPartiesBody.json index 67faec305..817843bd0 100644 --- a/src/test/unit/data/putPartiesBody.json +++ b/src/test/unit/data/putPartiesBody.json @@ -3,8 +3,8 @@ "accounts": { "account": [ { - "”currency": "USD", - "”description": "savings", + "currency": "USD", + "description": "savings", "address": "moja.red.8f027046-b82a5456-4fa9-838b-70210fcf8136" }, { @@ -31,4 +31,4 @@ "name": "John Doe", "merchantClassificationCode": "1234" } -} +} \ No newline at end of file diff --git a/src/test/unit/inboundApi/handlers.test.js b/src/test/unit/inboundApi/handlers.test.js index b4e318b42..b620c2417 100644 --- a/src/test/unit/inboundApi/handlers.test.js +++ b/src/test/unit/inboundApi/handlers.test.js @@ -7,6 +7,7 @@ * ORIGINAL AUTHOR: * * Vassilis Barzokas - vassilis.barzokas@modusbox.com * * Paweł Marzec - pawel.marzec@modusbox.com * + * Sridhar Voruganti - sridhar.voruganti@modusbox.com * **************************************************************************/ 'use strict'; @@ -17,24 +18,28 @@ const handlers = require('../../../InboundServer/handlers'); const Model = require('@internal/model').InboundTransfersModel; const mockArguments = require('./data/mockArguments'); const mockTransactionRequestData = require('./data/mockTransactionRequest'); +const mockAuthorizationArguments = require('../lib/model/data/mockAuthorizationArguments.json'); const mockLogger = require('../mockLogger'); const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; +const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; +const ThirdpartyTrxnModelOut = require('@internal/model').OutboundThirdpartyTransactionModel; describe('Inbound API handlers:', () => { let mockArgs; let mockTransactionRequest; + let mockAuthReqArgs; beforeEach(() => { - mockArgs = JSON.parse(JSON.stringify(mockArguments)); - mockTransactionRequest = JSON.parse(JSON.stringify(mockTransactionRequestData)); - + mockArgs = deepClone(mockArguments); + mockTransactionRequest = deepClone(mockTransactionRequestData); + mockAuthReqArgs = deepClone(mockAuthorizationArguments); }); describe('POST /quotes', () => { - + let mockContext; - + beforeEach(() => { mockContext = { request: { @@ -46,11 +51,10 @@ describe('Inbound API handlers:', () => { response: {}, state: { conf: {}, - // example of elaborative logging with keepQuite = false - logger: mockLogger( { app: 'inbound-handlers-unit-test' }, false ) + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) } }; - + }); test('calls `model.quoteRequest` with the expected arguments.', async () => { @@ -59,19 +63,20 @@ describe('Inbound API handlers:', () => { await expect(handlers['/quotes'].post(mockContext)).resolves.toBe(undefined); expect(quoteRequestSpy).toHaveBeenCalledTimes(1); - expect(quoteRequestSpy.mock.calls[0][0]).toBe(mockContext.request.body); - expect(quoteRequestSpy.mock.calls[0][1]).toBe(mockContext.request.headers['fspiop-source']); + expect(quoteRequestSpy).toHaveBeenCalledWith( + mockContext.request.body, + mockContext.request.headers['fspiop-source']); }); - + }); describe('POST /transactionRequests', () => { - + let mockTransactionReqContext; beforeEach(() => { - + mockTransactionReqContext = { request: { body: mockTransactionRequest.transactionRequest, @@ -82,7 +87,7 @@ describe('Inbound API handlers:', () => { response: {}, state: { conf: {}, - logger: mockLogger( { app: 'inbound-handlers-unit-test' } ) + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) } }; }); @@ -93,17 +98,50 @@ describe('Inbound API handlers:', () => { await expect(handlers['/transactionRequests'].post(mockTransactionReqContext)).resolves.toBe(undefined); expect(transactionRequestSpy).toHaveBeenCalledTimes(1); - expect(transactionRequestSpy.mock.calls[0][0]).toBe(mockTransactionReqContext.request.body); - expect(transactionRequestSpy.mock.calls[0][1]).toBe(mockTransactionReqContext.request.headers['fspiop-source']); + expect(transactionRequestSpy).toHaveBeenCalledWith( + mockTransactionReqContext.request.body, + mockTransactionReqContext.request.headers['fspiop-source']); + }); + }); + + describe('POST /authorizations', () => { + + let mockAuthorizationContext; + + beforeEach(() => { + + mockAuthorizationContext = { + request: { + body: mockAuthReqArgs.authorizationRequest, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) + } + }; + }); + + test('calls `ThirdpartyTrxnModelIn.postAuthorizations` with the expected arguments.', async () => { + const authorizationRequestSpy = jest.spyOn(ThirdpartyTrxnModelIn.prototype, 'postAuthorizations'); + + await expect(handlers['/authorizations'].post(mockAuthorizationContext)).resolves.toBe(undefined); + expect(authorizationRequestSpy).toHaveBeenCalledTimes(1); + expect(authorizationRequestSpy).toHaveBeenCalledWith( + mockAuthorizationContext.request.body, + mockAuthorizationContext.request.headers['fspiop-source']); }); }); describe('GET /authorizations', () => { - + let mockAuthorizationContext; beforeEach(() => { - + mockAuthorizationContext = { request: { headers: { @@ -113,12 +151,12 @@ describe('Inbound API handlers:', () => { response: {}, state: { conf: {}, - path : { - params : { + path: { + params: { 'ID': '1234' } }, - logger: mockLogger( { app: 'inbound-handlers-unit-test' } ) + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) } }; }); @@ -129,15 +167,17 @@ describe('Inbound API handlers:', () => { await expect(handlers['/authorizations/{ID}'].get(mockAuthorizationContext)).resolves.toBe(undefined); expect(authorizationsSpy).toHaveBeenCalledTimes(1); - expect(authorizationsSpy.mock.calls[0][1]).toBe(mockAuthorizationContext.request.headers['fspiop-source']); + expect(authorizationsSpy).toHaveBeenCalledWith( + mockAuthorizationContext.state.path.params.ID, + mockAuthorizationContext.request.headers['fspiop-source']); }); }); describe('PISP PUT /authorizations', () => { - + let mockAuthorizationContext; beforeEach(() => { - + mockAuthorizationContext = { request: { headers: { @@ -152,12 +192,12 @@ describe('Inbound API handlers:', () => { conf: { enablePISPMode: true }, - path : { - params : { + path: { + params: { 'ID': '1234' } }, - logger: mockLogger( { app: 'inbound-handlers-unit-test' } ), + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), // there is no need to mock redis but only Cache cache: { publish: jest.fn(() => Promise.resolve()) @@ -185,10 +225,10 @@ describe('Inbound API handlers:', () => { }); describe('DFSP PUT /authorizations', () => { - + let mockAuthorizationContext; beforeEach(() => { - + mockAuthorizationContext = { request: { headers: { @@ -203,12 +243,12 @@ describe('Inbound API handlers:', () => { conf: { enablePISPMode: false }, - path : { - params : { + path: { + params: { 'ID': '1234' } }, - logger: mockLogger( { app: 'inbound-handlers-unit-test' } ), + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), // there is no need to mock redis but only Cache cache: { publish: jest.fn(() => Promise.resolve()) @@ -231,4 +271,51 @@ describe('Inbound API handlers:', () => { }); }); + describe('PUT /thirdPartyRequests/transactions', () => { + let mockThirdPartyReqContext; + beforeEach(() => { + mockThirdPartyReqContext = { + request: { + headers: { + 'fspiop-source': 'foo' + }, + body: { + the: 'body' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), + cache: { + publish: jest.fn(() => Promise.resolve()) + }, + } + }; + }); + + test('calls `model.thirdPartyRequests.transactions` with the expected arguments.', async () => { + const pubNotificatiosnSpy = jest.spyOn(ThirdpartyTrxnModelOut, 'publishNotifications'); + + await expect(handlers['/thirdPartyRequests/transactions/{ID}'].put(mockThirdPartyReqContext)).resolves.toBe(undefined); + + expect(pubNotificatiosnSpy).toHaveBeenCalledTimes(1); + expect(pubNotificatiosnSpy).toHaveBeenCalledWith(mockThirdPartyReqContext.state.cache, + mockThirdPartyReqContext.state.path.params.ID, { + type: 'thirdPartyTransactionsReqResponse', + data: mockThirdPartyReqContext.request.body, + headers: mockThirdPartyReqContext.request.headers + }); + }); + }); + }); + +function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/src/test/unit/lib/model/InboundThirdpartyTransactionModel.test.js b/src/test/unit/lib/model/InboundThirdpartyTransactionModel.test.js new file mode 100644 index 000000000..f65b92ada --- /dev/null +++ b/src/test/unit/lib/model/InboundThirdpartyTransactionModel.test.js @@ -0,0 +1,63 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Sridhar Voruganti - sridhar.voruganti@modusbox.com * + **************************************************************************/ +'use strict'; + +// we use a mock standard components lib to intercept and mock certain funcs +jest.mock('@mojaloop/sdk-standard-components'); + +const defaultConfig = require('./data/defaultConfig'); +const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; +const mockAuthorizationArguments = require('./data/mockAuthorizationArguments'); +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { BackendRequests } = require('@internal/requests'); +const mockLogger = require('../../mockLogger'); + +describe('inboundThirdpartyTransactionModel', () => { + let config; + let mockAuthReqArgs; + let logger; + + beforeEach(async () => { + config = deepClone(defaultConfig); + mockAuthReqArgs = deepClone(mockAuthorizationArguments); + logger = mockLogger({ app: 'InboundThirdpartyTransactionModel-test' }); + }); + + describe('authorizations', () => { + let model; + + beforeEach(async () => { + BackendRequests.__getSignedChallenge = jest.fn().mockReturnValue( + Promise.resolve(mockAuthReqArgs.internalSignedChallengeResponse)); + + model = new ThirdpartyTrxnModelIn({ + ...config, + logger + }); + }); + + afterEach(async () => { + MojaloopRequests.__putAuthorizations.mockClear(); + }); + + test('calls `mojaloopRequests.putAuthorizations` with the expected arguments.', async () => { + await model.postAuthorizations(mockAuthReqArgs.authorizationRequest, mockAuthReqArgs.fspId); + expect(MojaloopRequests.__putAuthorizations).toHaveBeenCalledTimes(1); + expect(MojaloopRequests.__putAuthorizations).toHaveBeenCalledWith( + mockAuthReqArgs.authorizationRequest.transactionRequestId, + mockAuthReqArgs.authorizationsResponse, mockAuthReqArgs.fspId); + }); + }); + +}); + +function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/src/test/unit/lib/model/data/mockAuthorizationArguments.json b/src/test/unit/lib/model/data/mockAuthorizationArguments.json new file mode 100644 index 000000000..0f618b5c8 --- /dev/null +++ b/src/test/unit/lib/model/data/mockAuthorizationArguments.json @@ -0,0 +1,60 @@ +{ + "authorizationRequest": { + "authenticationType": "U2F", + "retriesLeft": 1, + "amount": { + "currency": "USD", + "amount": "100" + }, + "transactionId": "2f169631-ef99-4cb1-96dc-91e8fc08f539", + "transactionRequestId": "02e28448-3c05-4059-b5f7-d518d0a2d8ea", + "quote": { + "transferAmount": { + "currency": "USD", + "amount": "100" + }, + "payeeReceiveAmount": { + "currency": "USD", + "amount": "99" + }, + "payeeFspFee": { + "currency": "USD", + "amount": "1" + }, + "payeeFspCommission": { + "currency": "USD", + "amount": "0" + }, + "expiration": "2020-05-17T15:28:54.250Z", + "geoCode": { + "latitude": "+45.4215", + "longitude": "+75.6972" + }, + "ilpPacket": "AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgy...", + "condition": "f5sqb7tBTWPd5Y8BDFdMm9BJR_MNI4isf8p8n4D5pHA", + "extensionList": { + "extension": [ + { + "key": "errorDescription1", + "value": "This is a more detailed error description" + } + ] + } + } + }, + "internalSignedChallengeResponse": { + "pinValue": "123456", + "counter": "1" + }, + "authorizationsResponse": { + "authenticationInfo": { + "authentication": "U2F", + "authenticationValue": { + "pinValue": "123456", + "counter": "1" + } + }, + "responseType": "ENTERED" + }, + "fspId": "fake-fsp-id" +} \ No newline at end of file From e21bb87b7383fdfe992f3bc5917916844ad41e46 Mon Sep 17 00:00:00 2001 From: eoln <2881004+eoln@users.noreply.github.com> Date: Thu, 9 Jul 2020 15:14:11 +0200 Subject: [PATCH 09/29] test: fix broken test (#177) --- src/OutboundServer/handlers.js | 4 +++- src/test/unit/outboundApi/handlers.test.js | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index fed2aa5bf..7b0ac3bf4 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -317,8 +317,10 @@ const postAuthorizations = async (ctx) => { wso2Auth: ctx.state.wso2Auth, }; + const cacheKey = `post_authorizations_${authorizationsRequest.transactionRequestId}`; + // use the authorizations model to execute asynchronous stages with the switch - const model = await OutboundAuthorizationsModel.create(authorizationsRequest, modelConfig); + const model = await OutboundAuthorizationsModel.create(authorizationsRequest, cacheKey, modelConfig); // run model's workflow const response = await model.run(); diff --git a/src/test/unit/outboundApi/handlers.test.js b/src/test/unit/outboundApi/handlers.test.js index 76d2c565a..8c4852bfb 100644 --- a/src/test/unit/outboundApi/handlers.test.js +++ b/src/test/unit/outboundApi/handlers.test.js @@ -237,7 +237,10 @@ describe('Outbound API handlers:', () => { const mockContext = { request: { - body: {the: 'body'}, + body: { + transactionRequestId: '123', + the: 'body' + }, headers: { 'fspiop-source': 'foo' } @@ -266,11 +269,15 @@ describe('Outbound API handlers:', () => { expect(createSpy).toBeCalledTimes(1); const request = mockContext.request; const state = mockContext.state; - expect(createSpy).toBeCalledWith(request.body, { - cache: state.cache, - logger: state.logger, - wso2Auth: state.wso2Auth - }); + expect(createSpy).toBeCalledWith( + request.body, + `post_authorizations_${request.body.transactionRequestId}`, + { + cache: state.cache, + logger: state.logger, + wso2Auth: state.wso2Auth + } + ); // run workflow expect(mockedPSM.run).toBeCalledTimes(1); From 5e8041521e0655ac9478c0ab6295ed8014cc991a Mon Sep 17 00:00:00 2001 From: sridharvoruganti <36686863+sridharvoruganti@users.noreply.github.com> Date: Fri, 10 Jul 2020 13:28:35 +0530 Subject: [PATCH 10/29] Merge mojaloop sdk-scheme-adapter 'master' into pisp/master (#178) * Renamed ALS_ENDPOINT_HOST to ALS_ENDPOINT * Bumped up the version * #1325: Bulk quotes and bulk transfers support (#159) * Initial commit for bulk transfers support * Initial commit for bulk quotes and bulkt transfers support * Complete inbound API bulk quotes implementation * Separate bulk quotes switch base URL * Add postTransfers and getBulkTransfers * Update Outbound API spec with bulk transfers endpoints * Update outbound API spec * Add outbound bulk transfers * Fix API spec bugs * Add bulk party lookup * Whitespace fixes * Add outbound bulk quotes requests * Remove used imports * Add outbound POST bulk transfers handler * Add outboud GET /bulkTransfers/{ID} * Add Jest config for test debugging * Add unit tests for bulk quotes and bulk transfers * Add tests for outbound API. Fix missing specs entries. * Add tests for outbound API /bulkQuotes abd /bulkTransfers * Add inbound GET /bulkQuotes/{ID} * Add unit tests for inbound GET bulkQuotesById, PUT bulkQuotesById, PUT bulkQuotesErrorById * Fix spec description for /bulkTransfers and variable initialization in InboundTransfersModel * Remove unreferenced spec entry. * Updates per code review * Updates per code review * Updates per code review * Add more unit tests for InboundTransfersModel. Mor code review changes * Remodel bulk quotes using single state state-machine pattern * Remodel bulk transfers using state machine pattern. Add GET bulk quotes endpoint * Update API spec * Fix tests * Add more unit tests for InboundTransfersModel * Add unit tests for OutboundBulkQuotesModel * Add tests for OutboundBulkTransfersModel * Fix linting issue * PR review updates. * Remove some IDE auto inserted whitespaces * PR review update Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> * Merge master into pisp: Fixed unit test, linting Co-authored-by: Vijay Kumar Co-authored-by: Yevhen Co-authored-by: Steven Oderayi Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> --- .gitignore | 4 +- src/.env.example | 5 +- src/InboundServer/api.yaml | 24 +- src/InboundServer/handlers.js | 296 +++++++++++ src/OutboundServer/api.yaml | 467 ++++++++++++++++- src/OutboundServer/handlers.js | 208 ++++++-- src/config.js | 4 +- src/lib/model/InboundTransfersModel.js | 407 +++++++++++++-- src/lib/model/OutboundBulkQuotesModel.js | 485 ++++++++++++++++++ src/lib/model/OutboundBulkTransfersModel.js | 476 +++++++++++++++++ src/lib/model/index.js | 14 +- src/lib/model/lib/requests/backendRequests.js | 37 ++ src/lib/model/lib/shared/index.js | 172 ++++++- src/package.json | 7 +- src/test/__mocks__/@internal/requests.js | 8 + .../@mojaloop/sdk-standard-components.js | 28 + src/test/config/integration.env | 1 + .../unit/inboundApi/data/mockArguments.json | 91 +++- src/test/unit/inboundApi/handlers.test.js | 315 +++++++++++- .../lib/model/InboundTransfersModel.test.js | 246 +++++++++ .../lib/model/OutboundBulkQuotesModel.test.js | 256 +++++++++ .../model/OutboundBulkTransfersModel.test.js | 251 +++++++++ .../unit/lib/model/data/bulkQuoteRequest.json | 27 + .../lib/model/data/bulkQuoteResponse.json | 35 ++ .../lib/model/data/bulkTransferFulfil.json | 13 + .../lib/model/data/bulkTransferRequest.json | 29 ++ .../data/getBulkTransfersBackendResponse.json | 42 ++ .../getBulkTransfersMojaloopResponse.json | 22 + .../unit/lib/model/data/mockArguments.json | 69 ++- .../outboundApi/data/bulkQuoteRequest.json | 28 + .../outboundApi/data/bulkTransferRequest.json | 28 + .../outboundApi/data/mockBulkQuoteError.json | 45 ++ .../data/mockBulkTransferError.json | 48 ++ src/test/unit/outboundApi/handlers.test.js | 198 ++++++- 34 files changed, 4242 insertions(+), 144 deletions(-) create mode 100644 src/lib/model/OutboundBulkQuotesModel.js create mode 100644 src/lib/model/OutboundBulkTransfersModel.js create mode 100644 src/test/unit/lib/model/OutboundBulkQuotesModel.test.js create mode 100644 src/test/unit/lib/model/OutboundBulkTransfersModel.test.js create mode 100644 src/test/unit/lib/model/data/bulkQuoteRequest.json create mode 100644 src/test/unit/lib/model/data/bulkQuoteResponse.json create mode 100644 src/test/unit/lib/model/data/bulkTransferFulfil.json create mode 100644 src/test/unit/lib/model/data/bulkTransferRequest.json create mode 100644 src/test/unit/lib/model/data/getBulkTransfersBackendResponse.json create mode 100644 src/test/unit/lib/model/data/getBulkTransfersMojaloopResponse.json create mode 100644 src/test/unit/outboundApi/data/bulkQuoteRequest.json create mode 100644 src/test/unit/outboundApi/data/bulkTransferRequest.json create mode 100644 src/test/unit/outboundApi/data/mockBulkQuoteError.json create mode 100644 src/test/unit/outboundApi/data/mockBulkTransferError.json diff --git a/.gitignore b/.gitignore index d3a8ba6a2..69c3e7865 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules/ .swp src/junit.xml -.DS_Store \ No newline at end of file +.DS_Store +.vscode +secrets/*.pem diff --git a/src/.env.example b/src/.env.example index f467c5e99..415eae8e4 100644 --- a/src/.env.example +++ b/src/.env.example @@ -55,11 +55,14 @@ CACHE_HOST=172.17.0.2 CACHE_PORT=6379 # SWITCH ENDPOINT -# The option 'PEER_ENDPOINT' has no effect if the remaining three options 'ALS_ENDPOINT', 'QUOTES_ENDPOINT', 'TRANSFERS_ENDPOINT' are specified. +# The option 'PEER_ENDPOINT' has no effect if the remaining five options 'ALS_ENDPOINT', 'QUOTES_ENDPOINT', +# 'BULK_QUOTES_ENDPOINT', 'TRANSFERS_ENDPOINT', 'BULK_TRANSFERS_ENDPOINT' are specified. PEER_ENDPOINT=172.17.0.3:4000 #ALS_ENDPOINT=account-lookup-service.local #QUOTES_ENDPOINT=quoting-service.local +#BULK_QUOTES_ENDPOINT=quoting-service.local #TRANSFERS_ENDPOINT=ml-api-adapter.local +#BULK_TRANSFERS_ENDPOINT=bulk-api-adapter.local # BACKEND ENDPOINT BACKEND_ENDPOINT=172.17.0.5:4000 diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index 95e2be9e2..5667b37e3 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -554,7 +554,7 @@ paths: $ref: '#/components/responses/ErrorResponse501' '503': $ref: '#/components/responses/ErrorResponse503' - /participants: + '/participants': post: description: >- The HTTP request POST /participants is used to create information in the @@ -1064,7 +1064,7 @@ paths: schema: $ref: '#/components/schemas/TransactionRequestsIDPutResponse' required: true - /transactionRequests: + '/transactionRequests': post: description: >- The HTTP request POST /transactionRequests is used to request the @@ -1318,7 +1318,7 @@ paths: schema: $ref: '#/components/schemas/QuotesIDPutResponse' required: true - /quotes: + '/quotes': post: description: >- The HTTP request POST /quotes is used to request the creation of a quote @@ -1774,7 +1774,7 @@ paths: $ref: '#/components/responses/ErrorResponse503' requestBody: $ref: '#/components/requestBodies/ErrorInformationObject' - /transfers: + '/transfers': post: description: >- The HTTP request POST /transfers is used to request the creation of a @@ -2173,7 +2173,7 @@ paths: schema: $ref: '#/components/schemas/BulkQuotesIDPutResponse' required: true - /bulkQuotes: + '/bulkQuotes': post: description: >- The HTTP request POST /bulkQuotes is used to request the creation of a @@ -2300,8 +2300,8 @@ paths: - $ref: '#/components/parameters/FSPIOP-HTTP-Method' get: description: >- - The HTTP request GET /bulkTransfers/ is used to get information - regarding an earlier created or requested bulk transfer. The in the URI + The HTTP request GET /bulkTransfers/{ID} is used to get information + regarding an earlier created or requested bulk transfer. The ID in the URI should contain the bulkTransferId that was used for the creation of the bulk transfer. summary: BulkTransferByID @@ -2331,10 +2331,10 @@ paths: $ref: '#/components/responses/ErrorResponse503' put: description: >- - The callback PUT /bulkTransfers/ is used to inform the client of a - requested or created bulk transfer. The in the URI should contain the + The callback PUT /bulkTransfers/{ID} is used to inform the client of a + requested or created bulk transfer. The ID in the URI should contain the bulkTransferId that was used for the creation of the bulk transfer (POST - /bulkTransfers), or the that was used in the GET /bulkTransfers/. + /bulkTransfers), or the that was used in the GET /bulkTransfers/{ID}. summary: BulkTransfersByIDPut tags: - bulkTransfers @@ -2411,7 +2411,7 @@ paths: schema: $ref: '#/components/schemas/BulkTransfersIDPutResponse' required: true - /bulkTransfers: + '/bulkTransfers': post: description: >- The HTTP request POST /bulkTransfers is used to request the creation of @@ -4708,4 +4708,4 @@ components: description: Unique routable address which is DFSP specific. pattern: ^([0-9A-Za-z_~\-\.]+[0-9A-Za-z_~\-])$ minLength: 1 - maxLength: 1023 \ No newline at end of file + maxLength: 1023 diff --git a/src/InboundServer/handlers.js b/src/InboundServer/handlers.js index 9d302dd86..d947f1729 100644 --- a/src/InboundServer/handlers.js +++ b/src/InboundServer/handlers.js @@ -6,6 +6,8 @@ * * * ORIGINAL AUTHOR: * * James Bush - james.bush@modusbox.com * + * CONTRIBUTORS: * + * Steven Oderayi - steven.oderayi@modusbox.com * * Paweł Marzec - pawel.marzec@modusbox.com * * Sridhar Voruganti - sridhar.voruganti@modusbox.com * **************************************************************************/ @@ -647,6 +649,280 @@ const putTransfersByIdError = async (ctx) => { ctx.response.body = ''; }; +/** + * Handles a GET /bulkQuotes/{ID} request + */ +const getBulkQuotesById = async (ctx) => { + // kick off an asyncronous operation to handle the request + (async () => { + try { + if (ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers + }; + const res = await ctx.state.cache.set( + `request_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); + } + + // use the transfers model to execute asynchronous stages with the switch + const model = new Model({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + const sourceFspId = ctx.request.headers['fspiop-source']; + + // use the model to handle the request + const response = await model.getBulkQuote(ctx.state.path.params.ID, + sourceFspId); + + // log the result + ctx.state.logger.push({response}). + log('Inbound transfers model handled GET /bulkQuotes/{ID} request'); + } + catch(err) { + // nothing we can do if an error gets thrown back to us here apart from log it and continue + ctx.state.logger.push({ err }).log('Error handling GET /bulkQuotes/{ID}'); + } + })(); + + // Note that we will have passed request validation, JWS etc... by this point + // so it is safe to return 202 + ctx.response.status = 202; + ctx.response.body = ''; +}; + +/** + * Handles a POST /bulkQuotes request + */ +const postBulkQuotes = async (ctx) => { + (async () => { + try { + if(ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`request_${ctx.request.body.bulkQuoteId}`, req); + ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + } + + // use the transfers model to execute asynchronous stages with the switch + const model = new Model({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + const sourceFspId = ctx.request.headers['fspiop-source']; + + // use the model to handle the request + const response = await model.bulkQuoteRequest(ctx.request.body, sourceFspId); + + // log the result + ctx.state.logger.push({ response }).log('Inbound transfers model handled POST /bulkQuotes request'); + } + catch(err) { + // nothing we can do if an error gets thrown back to us here apart from log it and continue + ctx.state.logger.push({ err }).log('Error handling POST /bulkQuotes'); + } + })(); + + ctx.response.status = 202; + ctx.response.body = ''; +}; + +/** + * Handles a PUT /bulkQuotes/{ID}. This is a response to a POST /bulkQuotes request + */ +const putBulkQuotesById = async (ctx) => { + if(ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + } + + // publish an event onto the cache for subscribers to action + await ctx.state.cache.publish(`bulkQuotes_${ctx.state.path.params.ID}`, { + type: 'bulkQuoteResponse', + data: ctx.request.body, + headers: ctx.request.headers + }); + + ctx.response.status = 200; +}; + +/** + * Handles a PUT /bulkQuotes/{ID}/error request. This is an error response to a POST /bulkQuotes request + */ +const putBulkQuotesByIdError = async(ctx) => { + if(ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + } + + // publish an event onto the cache for subscribers to action + await ctx.state.cache.publish(`bulkQuotes_${ctx.state.path.params.ID}`, { + type: 'bulkQuoteResponseError', + data: ctx.request.body + }); + + ctx.response.status = 200; + ctx.response.body = ''; +}; + +/** + * Handles a GET /bulkTransfers/{ID} request + */ +const getBulkTransfersById = async (ctx) => { + // kick off an asyncronous operation to handle the request + (async () => { + try { + if (ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers + }; + const res = await ctx.state.cache.set( + `request_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); + } + + // use the transfers model to execute asynchronous stages with the switch + const model = new Model({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + const sourceFspId = ctx.request.headers['fspiop-source']; + + // use the model to handle the request + const response = await model.getBulkTransfer(ctx.state.path.params.ID, + sourceFspId); + + // log the result + ctx.state.logger.push({response}). + log('Inbound transfers model handled GET /bulkTransfers/{ID} request'); + } + catch(err) { + // nothing we can do if an error gets thrown back to us here apart from log it and continue + ctx.state.logger.push({ err }).log('Error handling GET /bulkTransfers/{ID}'); + } + })(); + + // Note that we will have passed request validation, JWS etc... by this point + // so it is safe to return 202 + ctx.response.status = 202; + ctx.response.body = ''; +}; + +/** + * Handles a POST /bulkTransfers request + */ +const postBulkTransfers = async (ctx) => { + (async () => { + try { + if(ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`request_${ctx.request.body.bulkTransferId}`, req); + ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + } + + // use the transfers model to execute asynchronous stages with the switch + const model = new Model({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + const sourceFspId = ctx.request.headers['fspiop-source']; + + // use the model to handle the request + const response = await model.prepareBulkTransfer(ctx.request.body, sourceFspId); + + // log the result + ctx.state.logger.push({ response }).log('Inbound transfers model handled POST /bulkTransfers request'); + } + catch(err) { + // nothing we can do if an error gets thrown back to us here apart from log it and continue + ctx.state.logger.push({ err }).log('Error handling POST /bulkTransfers'); + } + })(); + + ctx.response.status = 202; + ctx.response.body = ''; +}; + +/** + * Handles a PUT /bulkTransfers/{ID}. This is a response to a POST /bulkTransfers request + */ +const putBulkTransfersById = async (ctx) => { + if(ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + } + + // publish an event onto the cache for subscribers to action + await ctx.state.cache.publish(`bulkTransfer_${ctx.state.path.params.ID}`, { + type: 'bulkTransferResponse', + data: ctx.request.body, + headers: ctx.request.headers + }); + + ctx.response.status = 200; +}; + +/** + * Handles a PUT /bulkTransfers/{ID}/error request. This is an error response to a POST /bulkTransfers request + */ +const putBulkTransfersByIdError = async(ctx) => { + if(ctx.state.conf.enableTestFeatures) { + // we are in test mode so cache the request + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + } + + // publish an event onto the cache for subscribers to action + await ctx.state.cache.publish(`bulkTransfer_${ctx.state.path.params.ID}`, { + type: 'bulkTransferResponseError', + data: ctx.request.body + }); + + ctx.response.status = 200; + ctx.response.body = ''; +}; + /** * Handles PUT /thirdPartyRequests/transactions/{ID} request. * This is response to a POST /thirdPartyRequests/transactions request @@ -713,6 +989,26 @@ module.exports = { get: getAuthorizationsById, put: putAuthorizationsById }, + '/bulkQuotes': { + post: postBulkQuotes + }, + '/bulkQuotes/{ID}': { + get: getBulkQuotesById, + put: putBulkQuotesById + }, + '/bulkQuotes/{ID}/error': { + put: putBulkQuotesByIdError + }, + '/bulkTransfers': { + post: postBulkTransfers + }, + '/bulkTransfers/{ID}': { + get: getBulkTransfersById, + put: putBulkTransfersById + }, + '/bulkTransfers/{ID}/error': { + put: putBulkTransfersByIdError + }, '/participants/{ID}': { put: putParticipantsById }, diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index c4cd7bd00..ae71f1577 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -25,8 +25,7 @@ paths: - Health responses: 200: - description: Returns empty body if the scheme adapter outbound transfers service is running. - + description: Returns empty body if the scheme adapter outbound transfers service is running. /transfers: post: summary: Sends money from one account to another @@ -126,6 +125,102 @@ paths: schema: $ref: '#/components/schemas/errorResponse' + /bulkTransfers: + post: + summary: Sends money from one account to multiple accounts + description: > + The HTTP request `POST /bulkTransfers` is used to request the movement of funds from payer DFSP to payees' DFSP. + tags: + - BulkTransfers + requestBody: + description: Bulk transfer request body + content: + application/json: + schema: + $ref: '#/components/schemas/bulkTransferRequest' + required: true + responses: + 200: + $ref: '#/components/responses/bulkTransferSuccess' + 400: + $ref: '#/components/responses/bulkTransferBadRequest' + 500: + $ref: '#/components/responses/bulkTransferServerError' + 504: + $ref: '#/components/responses/bulkTransferTimeout' + + /bulkTransfers/{bulkTransferId}: + get: + summary: Retrieves information for a specific bulk transfer + description: >- + The HTTP request `GET /bulkTransfers/{bulktTransferId}` is used to get information regarding a bulk transfer created or requested earlier. + The `{bulkTransferId}` in the URI should contain the `bulkTransferId` that was used for the creation of the bulk transfer. + tags: + - BulkTransfers + parameters: + - $ref: '#/components/parameters/bulkTransferId' + responses: + 200: + description: Bulk transfer information successfully retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/bulkTransferStatusResponse' + 500: + description: An error occurred processing the bulk transfer + content: + application/json: + schema: + $ref: '#/components/schemas/errorResponse' + + /bulkQuotes: + post: + summary: Request bulk quotes for the provided financial transactions + description: > + The HTTP request `POST /bulkQuotes` is used to request a bulk quote to fascilitate funds transfer from payer DFSP to payees' DFSP. + tags: + - BulkQuotes + requestBody: + description: Bulk quote request body + content: + application/json: + schema: + $ref: '#/components/schemas/bulkQuoteRequest' + required: true + responses: + 200: + $ref: '#/components/responses/bulkQuoteSuccess' + 400: + $ref: '#/components/responses/bulkQuoteBadRequest' + 500: + $ref: '#/components/responses/bulkQuoteServerError' + 504: + $ref: '#/components/responses/bulkQuoteTimeout' + + /bulkQuotes/{bulkQuoteId}: + get: + summary: Retrieves information for a specific bulk quote + description: >- + The HTTP request `GET /bulkQuotes/{bulktQuoteId}` is used to get information regarding a bulk quote created or requested earlier. + The `{bulkQuoteId}` in the URI should contain the `bulkQuoteId` that was used for the creation of the bulk quote. + tags: + - BulkQuotes + parameters: + - $ref: '#/components/parameters/bulkQuoteId' + responses: + 200: + description: Bulk quote information successfully retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/bulkQuoteStatusResponse' + 500: + description: An error occurred processing the bulk quote + content: + application/json: + schema: + $ref: '#/components/schemas/errorResponse' + /requestToPay: post: summary: Receiver requesting funds from Sender @@ -491,6 +586,16 @@ components: transferState: $ref: '#/components/schemas/transferResponse' + bulkTransferErrorResponse: + allOf: + - $ref: '#/components/schemas/errorResponse' + - type: object + required: + - bulkTansferState + properties: + bulkTransferState: + $ref: '#/components/schemas/bulkTransferResponse' + errorAccountsResponse: allOf: - $ref: '#/components/schemas/errorResponse' @@ -994,6 +1099,23 @@ components: or an object representing other types of error e.g. exceptions that may occur inside the scheme adapter. $ref: '#/components/schemas/transferError' + bulkTransferResponse: + type: object + required: + - from + - individualTransferResults + properties: + transferId: + $ref: '#/components/schemas/mojaloopIdentifier' + from: + $ref: '#/components/schemas/transferParty' + individualTransferResults: + type: array + maxItems: 1000 + items: + $ref: '#/components/schemas/individualTransferResult' + description: List of individual transfer result in a bulk transfer response. + transferStatusResponse: type: object required: @@ -1007,6 +1129,42 @@ components: $ref: '#/components/schemas/transferStatus' fulfil: $ref: '#/components/schemas/transferFulfilment' + + bulkTransferStatusResponse: + type: object + required: + - bulkTransferId + - currentState + - fulfils + properties: + bulkTransferId: + $ref: '#/components/schemas/mojaloopIdentifier' + currentState: + $ref: '#/components/schemas/bulkTransferStatus' + fulfils: + type: array + minItems: 1 + maxItems: 1000 + items: + $ref: '#/components/schemas/individualTransferFulfilment' + + bulkQuoteStatusResponse: + type: object + required: + - bulkQuoteId + - currentState + - individualQuotes + properties: + bulkQuoteId: + $ref: '#/components/schemas/mojaloopIdentifier' + currentState: + $ref: '#/components/schemas/bulkQuoteStatus' + individualQuotes: + type: array + minItems: 1 + maxItems: 1000 + items: + $ref: '#/components/schemas/individualQuote' quote: type: object @@ -1138,6 +1296,19 @@ components: $ref: '#/components/schemas/mojaloopAuthenticationType' authenticationValue: $ref: '#/components/schemas/mojaloopAuthenticationValue' + + individualTransferFulfilment: + type: object + description: A Mojaloop API transfer fulfilment for individual transfers in a bulk transfer + properties: + fulfilment: + $ref: '#/components/schemas/ilpFulfilment' + description: > + Fulfilment of the condition specified with the transaction. + Mandatory if transfer has completed successfully. + extensionList: + $ref: '#/components/schemas/extensionListComplex' + description: 'Optional extension, specific to deployment.' mojaloopTransferState: type: string @@ -1305,6 +1476,18 @@ components: - WAITING_FOR_QUOTE_ACCEPTANCE - COMPLETED + bulkTransferStatus: + type: string + enum: + - ERROR_OCCURRED + - COMPLETED + + bulkQuoteStatus: + type: string + enum: + - ERROR_OCCURRED + - COMPLETED + geoCode: type: object description: > @@ -1326,14 +1509,14 @@ components: ^(\+|-)?(?:90(?:(?:\.0{1,6})?)|(?:[0-9]|[1-8][0-9])(?:(?:\.[0-9]{1,6})?))$ description: > The API data type Latitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. longitude: type: string pattern: >- ^(\+|-)?(?:180(?:(?:\.0{1,6})?)|(?:[0-9]|[1-9][0-9]|1[0-7][0-9])(?:(?:\.[0-9]{1,6})?))$ description: >- The API data type Longitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. ilpCondition: type: string @@ -1456,7 +1639,213 @@ components: resend of the authentication value. TODO: enum specification will be defined when all values will be known type: string - + + bulkTransferRequest: + type: object + required: + - homeTransactionId + - from + - individualTransfers + properties: + homeTransactionId: + type: string + description: Transaction ID from the DFSP backend, used to reconcile transactions between the Switch and DFSP backend systems. + bulkTransferId: + $ref: '#/components/schemas/mojaloopIdentifier' + from: + $ref: '#/components/schemas/transferParty' + individualTransfers: + description: List of individual transfers in a bulk transfer. + type: array + minItems: 1 + maxItems: 1000 + items: + $ref: '#/components/schemas/individualTransfer' + extensions: + $ref: '#/components/schemas/extensionList' + + individualTransfer: + title: IndividualTransfer + type: object + description: Data model for the complex type 'individualTransfer'. + properties: + transferId: + $ref: '#/components/schemas/mojaloopIdentifier' + to: + $ref: '#/components/schemas/transferParty' + amountType: + $ref: '#/components/schemas/amountType' + currency: + $ref: '#/components/schemas/currency' + amount: + $ref: '#/components/schemas/money' + transactionType: + $ref: '#/components/schemas/transactionType' + note: + maxLength: 128 + type: string + extensions: + $ref: '#/components/schemas/extensionList' + required: + - transferId + - to + - amountType + - currency + - transactionType + + individualTransferResult: + type: object + properties: + transferId: + $ref: '#/components/schemas/mojaloopIdentifier' + to: + $ref: '#/components/schemas/transferParty' + amountType: + $ref: '#/components/schemas/amountType' + currency: + $ref: '#/components/schemas/currency' + amount: + $ref: '#/components/schemas/money' + transactionType: + $ref: '#/components/schemas/transactionType' + note: + maxLength: 128 + type: string + quoteId: + $ref: '#/components/schemas/mojaloopIdentifier' + quoteResponse: + $ref: '#/components/schemas/quote' + quoteResponseSource: + type: string + description: > + FSPID of the entity that supplied the quote response. This may not be the same as the FSPID of the entity which + owns the end user account in the case of a FOREX transfer. i.e. it may be a FOREX gateway. + fulfil: + $ref: '#/components/schemas/transferFulfilment' + lastError: + description: > + Object representing the last error to occur during a transfer process. This may be a Mojaloop API error returned from another entity in the scheme + or an object representing other types of error e.g. exceptions that may occur inside the scheme adapter. + $ref: '#/components/schemas/transferError' + + bulkQuoteRequest: + type: object + required: + - homeTransactionId + - from + - individualQuotes + properties: + homeTransactionId: + type: string + description: Transaction ID from the DFSP backend, used to reconcile transactions between the Switch and DFSP backend systems. + bulkQuoteId: + $ref: '#/components/schemas/mojaloopIdentifier' + from: + $ref: '#/components/schemas/transferParty' + individualQuotes: + description: List of individual quotes in a bulk quote. + type: array + minItems: 1 + maxItems: 1000 + items: + $ref: '#/components/schemas/individualQuote' + extensions: + $ref: '#/components/schemas/extensionList' + + individualQuote: + title: IndividualQuote + type: object + description: Data model for the complex type 'individualQuote'. + properties: + quoteId: + $ref: '#/components/schemas/mojaloopIdentifier' + to: + $ref: '#/components/schemas/transferParty' + amountType: + $ref: '#/components/schemas/amountType' + currency: + $ref: '#/components/schemas/currency' + amount: + $ref: '#/components/schemas/money' + transactionType: + $ref: '#/components/schemas/transactionType' + note: + maxLength: 128 + type: string + extensions: + $ref: '#/components/schemas/extensionList' + required: + - quoteId + - to + - amountType + - currency + - transactionType + + bulkQuoteResponse: + type: object + required: + - from + - individualQuoteResults + properties: + quoteId: + $ref: '#/components/schemas/mojaloopIdentifier' + homeTransactionId: + type: string + description: Transaction ID from the DFSP backend, used to reconcile transactions between the Switch and DFSP backend systems. + from: + $ref: '#/components/schemas/transferParty' + individualQuoteResults: + type: array + maxItems: 1000 + items: + $ref: '#/components/schemas/individualQuoteResult' + description: List of individualQuoteResults in a bulk transfer response. + + individualQuoteResult: + type: object + properties: + quoteId: + $ref: '#/components/schemas/mojaloopIdentifier' + to: + $ref: '#/components/schemas/transferParty' + amountType: + $ref: '#/components/schemas/amountType' + currency: + $ref: '#/components/schemas/currency' + amount: + $ref: '#/components/schemas/money' + transactionType: + $ref: '#/components/schemas/transactionType' + note: + maxLength: 128 + type: string + lastError: + description: > + Object representing the last error to occur during a quote process. This may be a Mojaloop API error returned from another entity in the scheme + or an object representing other types of error e.g. exceptions that may occur inside the scheme adapter. + $ref: '#/components/schemas/quoteError' + + quoteError: + type: object + description: This object represents a Mojaloop API error received at any time during the quote process + properties: + httpStatusCode: + type: integer + description: The HTTP status code returned to the caller. This is the same as the actual HTTP status code returned with the response. + mojaloopError: + description: If a quote process results in an error callback during the asynchronous Mojaloop API exchange, this property will contain the underlying Mojaloop API error object. + $ref: '#/components/schemas/mojaloopError' + + bulkQuoteErrorResponse: + allOf: + - $ref: '#/components/schemas/errorResponse' + - type: object + required: + - bulkTansferState + properties: + bulkQuoteState: + $ref: '#/components/schemas/bulkQuoteResponse' + responses: transferSuccess: description: Transfer completed successfully @@ -1483,6 +1872,56 @@ components: schema: $ref: '#/components/schemas/errorTransferResponse' + bulkTransferSuccess: + description: Bulk transfer completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/bulkTransferResponse' + bulkTransferBadRequest: + description: Malformed or missing required body, headers or parameters + content: + application/json: + schema: + $ref: '#/components/schemas/bulkTransferErrorResponse' + bulkTransferServerError: + description: An error occurred processing the bulk transfer + content: + application/json: + schema: + $ref: '#/components/schemas/bulkTransferErrorResponse' + bulkTransferTimeout: + description: Timeout occurred processing the bulk transfer + content: + application/json: + schema: + $ref: '#/components/schemas/bulkTransferErrorResponse' + + bulkQuoteSuccess: + description: Bulk quote completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/bulkQuoteResponse' + bulkQuoteBadRequest: + description: Malformed or missing required body, headers or parameters + content: + application/json: + schema: + $ref: '#/components/schemas/bulkQuoteErrorResponse' + bulkQuoteServerError: + description: An error occurred processing the bulk quote + content: + application/json: + schema: + $ref: '#/components/schemas/bulkQuoteErrorResponse' + bulkQuoteTimeout: + description: Timeout occurred processing the bulk quote + content: + application/json: + schema: + $ref: '#/components/schemas/bulkQuoteErrorResponse' + accountsCreationCompleted: description: Accounts creation completed content: @@ -1519,14 +1958,12 @@ components: application/json: schema: $ref: '#/components/schemas/errorTransferResponse' - authorizationsResponse: description: authorization response content: application/json: schema: $ref: '#/components/schemas/authorizationsResponse' - parameters: transferId: @@ -1537,6 +1974,22 @@ components: pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ type: string description: Identifier of the transfer to continue as returned in the response to a `POST /transfers` request. + bulkTransferId: + name: bulkTransferId + in: path + required: true + schema: + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + type: string + description: Identifier of the bulk transfer to continue as returned in the response to a `POST /bulkTransfers` request. + bulkQuoteId: + name: bulkQuoteId + in: path + required: true + schema: + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + type: string + description: Identifier of the bulk transfer to continue as returned in the response to a `POST /bulkTransfers` request. requestToPayTransactionId: name: requestToPayTransactionId in: path diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index 7b0ac3bf4..1cc1d70eb 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -6,18 +6,22 @@ * * * ORIGINAL AUTHOR: * * James Bush - james.bush@modusbox.com * + * CONTRIBUTORS: * + * Steven Oderayi - steven.oderayi@modusbox.com * **************************************************************************/ 'use strict'; const util = require('util'); -const { +const { AccountsModel, OutboundTransfersModel, + OutboundBulkTransfersModel, OutboundRequestToPayTransferModel, OutboundRequestToPayModel, - OutboundAuthorizationsModel + OutboundBulkQuotesModel, + OutboundAuthorizationsModel, } = require('@internal/model'); @@ -66,6 +70,12 @@ const handleError = (method, err, ctx, stateField) => { const handleTransferError = (method, err, ctx) => handleError(method, err, ctx, 'transferState'); +const handleBulkTransferError = (method, err, ctx) => + handleError(method, err, ctx, 'bulkTransferState'); + +const handleBulkQuoteError = (method, err, ctx) => + handleError(method, err, ctx, 'bulkQuoteState'); + const handleAccountsError = (method, err, ctx) => handleError(method, err, ctx, 'executionState'); @@ -110,17 +120,18 @@ const postTransfers = async (ctx) => { }; /** - * Handler for outbound transfer request initiation + * Handler for outbound transfer request */ -const postRequestToPayTransfer = async (ctx) => { +const getTransfers = async (ctx) => { try { - // this requires a multi-stage sequence with the switch. - let requestToPayTransferRequest = { - ...ctx.request.body + let transferRequest = { + ...ctx.request.body, + transferId: ctx.state.path.params.transferId, + currentState: 'getTransfer', }; - // use the merchant transfers model to execute asynchronous stages with the switch - const model = new OutboundRequestToPayTransferModel({ + // use the transfers model to execute asynchronous stages with the switch + const model = new OutboundTransfersModel({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, @@ -128,28 +139,25 @@ const postRequestToPayTransfer = async (ctx) => { }); // initialize the transfer model and start it running - await model.initialize(requestToPayTransferRequest); + await model.initialize(transferRequest); const response = await model.run(); + // return the result ctx.response.status = 200; ctx.response.body = response; } catch(err) { - return handleRequestToPayTransferError('postRequestToPayTransfer', err, ctx); + return handleTransferError('getTransfers', err, ctx); } }; /** - * Handler for outbound transfer request + * Handler for resuming outbound transfers in scenarios where two-step transfers are enabled + * by disabling the autoAcceptQuote SDK option */ -const getTransfers = async (ctx) => { +const putTransfers = async (ctx) => { try { - let transferRequest = { - ...ctx.request.body, - transferId: ctx.state.path.params.transferId, - currentState: 'getTransfer', - }; - + // this requires a multi-stage sequence with the switch. // use the transfers model to execute asynchronous stages with the switch const model = new OutboundTransfersModel({ ...ctx.state.conf, @@ -158,8 +166,11 @@ const getTransfers = async (ctx) => { wso2Auth: ctx.state.wso2Auth, }); - // initialize the transfer model and start it running - await model.initialize(transferRequest); + // TODO: check the incoming body to reject party or quote when requested to do so + + // load the transfer model from cache and start it running again + await model.load(ctx.state.path.params.transferId); + const response = await model.run(); // return the result @@ -167,40 +178,158 @@ const getTransfers = async (ctx) => { ctx.response.body = response; } catch(err) { - return handleTransferError('getTransfers', err, ctx); + return handleTransferError('putTransfers', err, ctx); } }; +/** + * Handler for outbound bulk transfer request + */ +const postBulkTransfers = async (ctx) => { + try { + // this requires a multi-stage sequence with the switch. + let bulkTransferRequest = { + ...ctx.request.body + }; + // use the bulk transfers model to execute asynchronous stages with the switch + const model = new OutboundBulkTransfersModel({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + await model.initialize(bulkTransferRequest); + const response = await model.run(); + + // return the result + ctx.response.status = 200; + ctx.response.body = response; + } + catch (err) { + return handleBulkTransferError('postBulkTransfers', err, ctx); + } +}; /** - * Handler for resuming outbound transfers in scenarios where two-step transfers are enabled - * by disabling the autoAcceptQuote SDK option + * Handler for outbound bulk transfer request */ -const putTransfers = async (ctx) => { +const getBulkTransfers = async (ctx) => { try { - // this requires a multi-stage sequence with the switch. - // use the transfers model to execute asynchronous stages with the switch - const model = new OutboundTransfersModel({ + const bulkTransferRequest = { + ...ctx.request.body, + bulkTransferId: ctx.state.path.params.bulkTransferId, + currentState: 'getBulkTransfer', + }; + + // use the bulk transfers model to execute asynchronous stages with the switch + const model = new OutboundBulkTransfersModel({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, wso2Auth: ctx.state.wso2Auth, }); - // TODO: check the incoming body to reject party or quote when requested to do so + await model.initialize(bulkTransferRequest); + const response = await model.getBulkTransfer(); - // load the transfer model from cache and start it running again - await model.load(ctx.state.path.params.transferId); + // return the result + ctx.response.status = 200; + ctx.response.body = response; + } + catch (err) { + return handleBulkTransferError('getBulkTransfers', err, ctx); + } +}; +/** + * Handler for outbound bulk quote request + */ +const postBulkQuotes = async (ctx) => { + try { + let bulkQuoteRequest = { + ...ctx.request.body + }; + + // use the bulk quotes model to execute asynchronous request with the switch + const model = new OutboundBulkQuotesModel({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + await model.initialize(bulkQuoteRequest); const response = await model.run(); // return the result ctx.response.status = 200; ctx.response.body = response; } - catch(err) { - return handleTransferError('putTransfers', err, ctx); + catch (err) { + return handleBulkQuoteError('postBulkQuotes', err, ctx); + } +}; + +/** + * Handler for outbound bulk quote request + */ +const getBulkQuoteById = async (ctx) => { + try { + const bulkQuoteRequest = { + ...ctx.request.body, + bulkQuoteId: ctx.state.path.params.bulkQuoteId, + currentState: 'getBulkQuote', + }; + + // use the bulk quotes model to execute asynchronous stages with the switch + const model = new OutboundBulkQuotesModel({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + await model.initialize(bulkQuoteRequest); + const response = await model.getBulkQuote(); + + // return the result + ctx.response.status = 200; + ctx.response.body = response; + } + catch (err) { + return handleBulkQuoteError('getBulkQuoteById', err, ctx); + } +}; + +/** + * Handler for outbound transfer request initiation + */ +const postRequestToPayTransfer = async (ctx) => { + try { + // this requires a multi-stage sequence with the switch. + let requestToPayTransferRequest = { + ...ctx.request.body + }; + + // use the merchant transfers model to execute asynchronous stages with the switch + const model = new OutboundRequestToPayTransferModel({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }); + + // initialize the transfer model and start it running + await model.initialize(requestToPayTransferRequest); + const response = await model.run(); + // return the result + ctx.response.status = 200; + ctx.response.body = response; + } + catch (err) { + return handleRequestToPayTransferError('postRequestToPayTransfer', err, ctx); } }; @@ -334,7 +463,6 @@ const postAuthorizations = async (ctx) => { } }; - module.exports = { '/': { get: healthCheck @@ -346,6 +474,18 @@ module.exports = { get: getTransfers, put: putTransfers }, + '/bulkTransfers': { + post: postBulkTransfers + }, + '/bulkTransfers/{bulkTransferId}': { + get: getBulkTransfers, + }, + '/bulkQuotes': { + post: postBulkQuotes, + }, + '/bulkQuotes/{bulkQuoteId}': { + get: getBulkQuoteById, + }, '/accounts': { post: postAccounts }, diff --git a/src/config.js b/src/config.js index 14cb76362..ae873085c 100644 --- a/src/config.js +++ b/src/config.js @@ -64,9 +64,11 @@ module.exports = { }, }, peerEndpoint: env.get('PEER_ENDPOINT').required().asString(), - alsEndpoint: env.get('ALS_ENDPOINT_HOST').asString(), + alsEndpoint: env.get('ALS_ENDPOINT').asString(), quotesEndpoint: env.get('QUOTES_ENDPOINT').asString(), + bulkQuotesEndpoint: env.get('BULK_QUOTES_ENDPOINT').asString(), transfersEndpoint: env.get('TRANSFERS_ENDPOINT').asString(), + bulkTransfersEndpoint: env.get('BULK_TRANSFERS_ENDPOINT').asString(), backendEndpoint: env.get('BACKEND_ENDPOINT').required().asString(), dfspId: env.get('DFSP_ID').default('mojaloop').asString(), diff --git a/src/lib/model/InboundTransfersModel.js b/src/lib/model/InboundTransfersModel.js index daa1558a3..6cd61137b 100644 --- a/src/lib/model/InboundTransfersModel.js +++ b/src/lib/model/InboundTransfersModel.js @@ -79,7 +79,7 @@ class InboundTransfersModel { authentication: 'OTP', authenticationValue: `${response.otpValue}` }, - responseType: 'ENTERED' + responseType: 'ENTERED' }; // make a callback to the source fsp with the party info return this._mojaloopRequests.putAuthorizations(transactionRequestId, mlAuthorization, sourceFspId); @@ -149,61 +149,6 @@ class InboundTransfersModel { } } - /** - * Queries details of a transfer - */ - async getTransfer(transferId, sourceFspId) { - try { - // make a call to the backend to get transfer details - const response = await this._backendRequests.getTransfers(transferId); - - if(!response) { - return 'No response from backend'; - } - - const ilpPaymentData = { - transferId: transferId, - homeTransactionId: response.homeTransactionId, - from: shared.internalPartyToMojaloopParty(response.from, response.from.fspId), - to: shared.internalPartyToMojaloopParty(response.to, response.to.fspId), - amountType: response.amountType, - currency: response.currency, - amount: response.amount, - transactionType: response.transactionType, - note: response.note, - }; - - let fulfilment; - if (this._dfspId === response.to.fspId) { - fulfilment = this._ilp.getResponseIlp(ilpPaymentData).fulfilment; - } - - // create a mojaloop transfer fulfil response - const mojaloopResponse = { - completedTimestamp: response.timestamp, - transferState: response.transferState, - fulfilment, - ...response.extensions && { - extensionList: { - extension: response.extensions, - }, - }, - }; - - // make a callback to the source fsp with the transfer fulfilment - return this._mojaloopRequests.putTransfers(transferId, mojaloopResponse, - sourceFspId); - } - catch(err) { - this._logger.push({ err }).log('Error in getTransfers'); - const mojaloopError = await this._handleError(err); - this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); - return this._mojaloopRequests.putTransfersError(transferId, - mojaloopError, sourceFspId); - } - } - - /** * Asks the backend for a response to an incoming quote request and makes a callback to the originator with * the result @@ -370,6 +315,356 @@ class InboundTransfersModel { } } + /** + * Queries details of a transfer + */ + async getTransfer(transferId, sourceFspId) { + try { + // make a call to the backend to get transfer details + const response = await this._backendRequests.getTransfers(transferId); + + if (!response) { + return 'No response from backend'; + } + + const ilpPaymentData = { + transferId: transferId, + homeTransactionId: response.homeTransactionId, + from: shared.internalPartyToMojaloopParty(response.from, response.from.fspId), + to: shared.internalPartyToMojaloopParty(response.to, response.to.fspId), + amountType: response.amountType, + currency: response.currency, + amount: response.amount, + transactionType: response.transactionType, + note: response.note, + }; + + let fulfilment; + if (this._dfspId === response.to.fspId) { + fulfilment = this._ilp.getResponseIlp(ilpPaymentData).fulfilment; + } + + // create a mojaloop transfer fulfil response + const mojaloopResponse = { + completedTimestamp: response.timestamp, + transferState: response.transferState, + fulfilment, + ...response.extensions && { + extensionList: { + extension: response.extensions, + }, + }, + }; + + // make a callback to the source fsp with the transfer fulfilment + return this._mojaloopRequests.putTransfers(transferId, mojaloopResponse, + sourceFspId); + } + catch (err) { + this._logger.push({ err }).log('Error in getTransfers'); + const mojaloopError = await this._handleError(err); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return this._mojaloopRequests.putTransfersError(transferId, + mojaloopError, sourceFspId); + } + } + + /** + * Asks the backend for a response to an incoming bulk quotes request and makes a callback to the originator with + * the results. + */ + async bulkQuoteRequest(bulkQuoteRequest, sourceFspId) { + const { bulkQuoteId } = bulkQuoteRequest; + const fulfilments = {}; + try { + const internalForm = shared.mojaloopBulkQuotesRequestToInternal(bulkQuoteRequest); + + // make a call to the backend to ask for bulk quotes response + const response = await this._backendRequests.postBulkQuotes(internalForm); + + if (!response) { + // make an error callback to the source fsp + return 'No response from backend'; + } + + if (!response.expiration) { + const expiration = new Date().getTime() + (this._expirySeconds * 1000); + response.expiration = new Date(expiration).toISOString(); + } + + // project our internal bulk quotes response into mojaloop bulk quotes response form + const mojaloopResponse = shared.internalBulkQuotesResponseToMojaloop(response); + + // create our ILP packet and condition and tag them on to our internal quote response + bulkQuoteRequest.individualQuotes.map((quote) => { + const quoteRequest = { + transactionId: quote.transactionId, + quoteId: quote.quoteId, + payee: quote.payee, + payer: bulkQuoteRequest.payer, + transactionType: quote.transactionType, + }; + // TODO: Optimize with a HashMap + const mojaloopIndividualQuote = mojaloopResponse.individualQuoteResults.find( + (quoteResult) => quoteResult.quoteId === quote.quoteId + ); + const quoteResponse = { + amount: mojaloopIndividualQuote.transferAmount, + note: mojaloopIndividualQuote.note || '', + }; + const { fulfilment, ilpPacket, condition } = this._ilp.getQuoteResponseIlp( + quoteRequest, quoteResponse); + + // mutate individual quotes in `mojaloopResponse` + mojaloopIndividualQuote.ilpPacket = ilpPacket; + mojaloopIndividualQuote.condition = condition; + + fulfilments[quote.quoteId] = fulfilment; + }); + + // now store the fulfilments and the bulk quotes data against the bulkQuoteId in our cache + await this._cache.set(`bulkQuotes_${bulkQuoteId}`, { + request: bulkQuoteRequest, + internalRequest: internalForm, + mojaloopResponse: mojaloopResponse, + response, + fulfilments + }); + + // make a callback to the source fsp with the quote response + return this._mojaloopRequests.putBulkQuotes(bulkQuoteId, mojaloopResponse, sourceFspId); + } + catch (err) { + this._logger.push({ err }).log('Error in bulkQuotesRequest'); + const mojaloopError = await this._handleError(err); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return await this._mojaloopRequests.putBulkQuotesError(bulkQuoteId, + mojaloopError, sourceFspId); + } + } + + /** + * Queries details of a bulk quote + */ + async getBulkQuote(bulkQuoteId, sourceFspId) { + try { + // make a call to the backend to get bulk quote details + const response = await this._backendRequests.getBulkQuotes(bulkQuoteId); + + if (!response) { + return 'No response from backend'; + } + + // project our internal quote reponse into mojaloop bulk quote response form + const mojaloopResponse = shared.internalBulkQuotesResponseToMojaloop(response); + + // make a callback to the source fsp with the bulk quote response + return this._mojaloopRequests.putBulkQuotes(bulkQuoteId, mojaloopResponse, + sourceFspId); + } + catch (err) { + this._logger.push({ err }).log('Error in getBulkQuote'); + const mojaloopError = await this._handleError(err); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return this._mojaloopRequests.putBulkQuotesError(bulkQuoteId, + mojaloopError, sourceFspId); + } + } + + /** + * Validates an incoming bulk transfer prepare request and makes a callback to the originator with + * the result + */ + async prepareBulkTransfer(bulkPrepareRequest, sourceFspId) { + try { + // retrieve bulk quote data + const bulkQuote = await this._cache.get(`bulkQuotes_${bulkPrepareRequest.bulkQuoteId}`); + + if (!bulkQuote) { + // Check whether to allow transfers without a previous quote. + if (!this._allowTransferWithoutQuote) { + throw new Error(`Corresponding bulk quotes not found for bulk transfers ${bulkPrepareRequest.bulkTransferId}`); + } + } + + // create an index of individual quote results indexed by transactionId for faster lookups + const quoteResultsByTrxId = {}; + + if (bulkQuote && bulkQuote.mojaloopResponse && bulkQuote.mojaloopResponse.individualQuoteResults) { + for (const quoteResult of bulkQuote.mojaloopResponse.individualQuoteResults) { + quoteResultsByTrxId[quoteResult.transactionId] = quoteResult; + } + } + + // transfer fulfilments + const fulfilments = {}; + + // collect errors for each transfer + let individualTransferErrors = []; + + // validate individual transfer + for (const transfer of bulkPrepareRequest.individualTransfers) { + // decode ilpPacked for this transfer to get transaction object + const transactionObject = this._ilp.getTransactionObject(transfer.ilpPacket); + + // we use the transactionId from the decoded ilpPacked in the transfer to match a corresponding quote + const quote = quoteResultsByTrxId[transactionObject.transactionId] || null; + + // calculate or retrieve fulfilments and conditions + let fulfilment = null; + let condition = null; + + if (quote) { + fulfilment = bulkQuote.fulfilments[quote.quoteId]; + condition = quote.condition; + } + else { + fulfilment = this._ilp.calculateFulfil(transfer.ilpPacket); + condition = this._ilp.calculateConditionFromFulfil(fulfilment); + } + + fulfilments[transfer.transferId] = fulfilment; + + // check incoming ILP matches our persisted values + if (this._checkIlp && (transfer.condition !== condition)) { + const transferError = this._handleError(new Error(`ILP condition in bulk transfers prepare for ${transfer.transferId} does not match quote`)); + individualTransferErrors.push({ transferId: transfer.transferId, transferError }); + } + } + + if (bulkQuote && this._rejectTransfersOnExpiredQuotes) { + const now = new Date(); + const expiration = new Date(bulkQuote.mojaloopResponse.expiration); + if (now > expiration) { + // TODO: Verify and align with actual schema for bulk transfers error endpoint + const error = Errors.MojaloopApiErrorObjectFromCode(Errors.MojaloopApiErrorCodes.QUOTE_EXPIRED); + this._logger.error(`Error in prepareBulkTransfers: bulk quotes expired for bulk transfers ${bulkPrepareRequest.bulkTransferId}, system time=${now.toISOString()} > quote time=${expiration.toISOString()}`); + return this._mojaloopRequests.putBulkTransfersError(bulkPrepareRequest.bulkTransferId, error, sourceFspId); + } + } + + if (individualTransferErrors.length) { + // TODO: Verify and align with actual schema for bulk transfers error endpoint + const mojaloopErrorResponse = { + bulkTransferState: 'REJECTED', + // eslint-disable-next-line no-unused-vars + individualTransferResults: individualTransferErrors.map(({ transferId, transferError }) => ({ + transferId, + errorInformation: transferError, + })) + }; + this._logger.push({ ...individualTransferErrors }).log('Error in prepareBulkTransfers'); + this._logger.push({ ...individualTransferErrors }).log(`Sending error response to ${sourceFspId}`); + + return await this._mojaloopRequests.putBulkTransfersError(bulkPrepareRequest.transferId, + mojaloopErrorResponse, sourceFspId); + } + + // project the incoming bulk transfer prepare into an internal bulk transfer request + const internalForm = shared.mojaloopBulkPrepareToInternalBulkTransfer(bulkPrepareRequest, bulkQuote, this._ilp); + + // make a call to the backend to inform it of the incoming bulk transfer + const response = await this._backendRequests.postBulkTransfers(internalForm); + + if (!response) { + // make an error callback to the source fsp + return 'No response from backend'; + } + + this._logger.log(`Bulk transfer accepted by backend returning homeTransactionId: ${response.homeTransactionId} for mojaloop bulk transferId: ${bulkPrepareRequest.bulkTransferId}`); + + // create a mojaloop transfer fulfil response + const mojaloopResponse = { + completedTimestamp: new Date(), + bulkTransferState: 'COMMITTED', + }; + + if (response.individualTransferResults && response.individualTransferResults.length) { + // eslint-disable-next-line no-unused-vars + mojaloopResponse.individualTransferResults = response.individualTransferResults.map((transfer) => { + return { + transferId: transfer.transferId, + fulfilment: fulfilments[transfer.transferId], + ...response.individualTransferResults[transfer.transferId].extensionList && { + extensionList: { + extension: response.individualTransferResults[transfer.transferId].extensionList, + }, + } + }; + }); + } + + // make a callback to the source fsp with the transfer fulfilment + return this._mojaloopRequests.putBulkTransfers(bulkPrepareRequest.transferId, mojaloopResponse, sourceFspId); + } + catch (err) { + this._logger.push({ err }).log('Error in prepareBulkTransfers'); + const mojaloopError = await this._handleError(err); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return await this._mojaloopRequests.putBulkTransfersError(bulkPrepareRequest.bulkTransferId, + mojaloopError, sourceFspId); + } + } + + /** + * Queries details of a bulk transfer + */ + async getBulkTransfer(bulkTransferId, sourceFspId) { + try { + // make a call to the backend to get bulk transfer details + const response = await this._backendRequests.getBulkTransfers(bulkTransferId); + + if (!response) { + return 'No response from backend'; + } + + let individualTransferResults = []; + + for (const transfer of response.internalRequest.individualTransfers) { + const ilpPaymentData = { + transferId: transfer.transferId, + to: shared.internalPartyToMojaloopParty(transfer.to, transfer.to.fspId), + amountType: transfer.amountType, + currency: transfer.currency, + amount: transfer.amount, + transactionType: transfer.transactionType, + note: transfer.note, + }; + let fulfilment; + if (this._dfspId === transfer.to.fspId) { + fulfilment = this._ilp.getResponseIlp(ilpPaymentData).fulfilment; + } + const transferResult = { transferId: transfer.transferId, fulfilment }; + transfer.errorInformation && (transferResult.errorInformation = transfer.errorInformation); + transfer.extensionList && (transferResult.extensionList = transfer.extensionList); + individualTransferResults.push(transferResult); + } + + // create a mojaloop bulk transfer fulfil response + const mojaloopResponse = { + completedTimestamp: response.timestamp, + bulkTransferState: response.bulkTransferState, + individualTransferResults, + ...response.extensions && { + extensionList: { + extension: response.extensions, + }, + }, + }; + + // make a callback to the source fsp with the bulk transfer fulfilments + return this._mojaloopRequests.putBulkTransfers(bulkTransferId, mojaloopResponse, + sourceFspId); + } + catch (err) { + this._logger.push({ err }).log('Error in getBulkTransfer'); + const mojaloopError = await this._handleError(err); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return this._mojaloopRequests.putBulkTransfersError(bulkTransferId, + mojaloopError, sourceFspId); + } + } + async _handleError(err) { let mojaloopErrorCode = Errors.MojaloopApiErrorCodes.INTERNAL_SERVER_ERROR; diff --git a/src/lib/model/OutboundBulkQuotesModel.js b/src/lib/model/OutboundBulkQuotesModel.js new file mode 100644 index 000000000..2adf87408 --- /dev/null +++ b/src/lib/model/OutboundBulkQuotesModel.js @@ -0,0 +1,485 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Steven Oderayi - steven.oderayi@modusbox.com * + **************************************************************************/ + +'use strict'; + +const util = require('util'); +const { uuid } = require('uuidv4'); +const StateMachine = require('javascript-state-machine'); +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const shared = require('@internal/shared'); +const { BackendError } = require('./common'); + +const stateEnum = { + 'ERROR_OCCURRED': 'ERROR_OCCURRED', + 'COMPLETED': 'COMPLETED', +}; + + +/** + * Models the state machine and operations required for performing an outbound bulk quote request + */ +class OutboundBulkQuotesModel { + constructor(config) { + this._cache = config.cache; + this._logger = config.logger; + this._requestProcessingTimeoutSeconds = config.requestProcessingTimeoutSeconds; + this._dfspId = config.dfspId; + this._expirySeconds = config.expirySeconds; + this._rejectExpiredQuoteResponses = config.rejectExpiredQuoteResponses; + + this._requests = new MojaloopRequests({ + logger: this._logger, + peerEndpoint: config.peerEndpoint, + bulkQuotesEndpoint: config.bulkQuotesEndpoint, + dfspId: config.dfspId, + tls: config.tls, + jwsSign: config.jwsSign, + jwsSigningKey: config.jwsSigningKey, + wso2Auth: config.wso2Auth + }); + } + + /** + * Initializes the internal state machine object + */ + _initStateMachine (initState) { + this.stateMachine = new StateMachine({ + init: initState, + transitions: [ + { name: 'requestBulkQuote', from: 'start', to: 'succeeded' }, + { name: 'getBulkQuote', to: 'succeeded' }, + { name: 'error', from: '*', to: 'errored' }, + ], + methods: { + onTransition: this._handleTransition.bind(this), + onAfterTransition: this._afterTransition.bind(this), + onPendingTransition: (transition, from, to) => { + // allow transitions to 'error' state while other transitions are in progress + if(transition !== 'error') { + throw new Error(`Transition requested while another transition is in progress: ${transition} from: ${from} to: ${to}`); + } + } + } + }); + + return this.stateMachine[initState]; + } + + /** + * Updates the internal state representation to reflect that of the state machine itself + */ + _afterTransition() { + this._logger.log(`State machine transitioned: ${this.data.currentState} -> ${this.stateMachine.state}`); + this.data.currentState = this.stateMachine.state; + } + + /** + * Initializes the bulk quotes model + * + * @param data {object} - The inbound API POST /bulkQuotes request body + */ + async initialize(data) { + this.data = data; + + // add a bulkQuoteId if one is not present e.g. on first submission + if(!this.data.hasOwnProperty('bulkQuoteId')) { + this.data.bulkQuoteId = uuid(); + } + + // initialize the state machine to its starting state + if(!this.data.hasOwnProperty('currentState')) { + this.data.currentState = 'start'; + } + + this._initStateMachine(this.data.currentState); + } + + /** + * Handles state machine transitions + */ + async _handleTransition(lifecycle, ...args) { + this._logger.log(`Bulk quote ${this.data.bulkQuoteId} is transitioning from ${lifecycle.from} to ${lifecycle.to} in response to ${lifecycle.transition}`); + + switch(lifecycle.transition) { + case 'init': + return; + + case 'requestBulkQuote': + return this._requestBulkQuote(); + + case 'getBulkQuote': + return this._getBulkQuote(this.data.bulkQuoteId); + + case 'error': + this._logger.log(`State machine is erroring with error: ${util.inspect(args)}`); + this.data.lastError = args[0] || new Error('unspecified error'); + break; + + default: + throw new Error(`Unhandled state transition for bulk quote ${this.data.bulkQuoteId}: ${util.inspect(args)}`); + } + } + + /** + * Requests a bulk quote + * Starts the quotes resolution process by sending a POST /bulkQuotes request to the switch; + * then waits for a notification from the cache that the quotes response has been received. + */ + async _requestBulkQuote() { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + // create a bulk quote request + const bulkQuote = this._buildBulkQuoteRequest(); + + // listen for events on the bulkQuoteId + const bulkQuoteKey = `bulkQuote_${bulkQuote.bulkQuoteId}`; + + // hook up a subscriber to handle response messages + const subId = await this._cache.subscribe(bulkQuoteKey, (cn, msg, subId) => { + try { + let error; + let message = JSON.parse(msg); + + if (message.type === 'bulkQuoteResponse') { + if (this._rejectExpiredQuoteResponses) { + const now = new Date().toISOString(); + if (now > bulkQuote.expiration) { + const msg = 'Bulk quote response missed expiry deadline'; + error = new BackendError(msg, 504); + this._logger.error(`${msg}: system time=${now} > expiration time=${bulkQuote.expiration}`); + } + } + } else if (message.type === 'bulkQuoteResponseError') { + error = new BackendError(`Got an error response requesting bulk quote: ${util.inspect(message.data, { depth: Infinity })}`, 500); + error.mojaloopError = message.data; + } + else { + this._logger.push({ message }).log(`Ignoring cache notification for bulk quote ${bulkQuoteKey}. Unknown message type ${message.type}.`); + return; + } + + // cancel the timeout handler + clearTimeout(timeout); + + // stop listening for bulk quote resolution messages + // no need to await for the unsubscribe to complete. + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkQuoteKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in callback) ${bulkQuoteKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + if (error) { + return reject(error); + } + + const bulkQuoteResponseBody = message.data; + this._logger.push({ bulkQuoteResponseBody }).log('Bulk quote response received'); + + return resolve(bulkQuoteResponseBody); + } + catch (err) { + return reject(err); + } + }); + + // set up a timeout for the request + const timeout = setTimeout(() => { + const err = new BackendError(`Timeout requesting bulk quote ${this.data.bulkQuoteId}`, 504); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkQuoteKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in timeout handler) ${bulkQuoteKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + }, this._requestProcessingTimeoutSeconds * 1000); + + // now we have a timeout handler and a cache subscriber hooked up we can fire off + // a POST /bulkQuotes request to the switch + try { + const res = await this._requests.postBulkQuotes(bulkQuote, this.data.individualQuotes[0].to.fspId); + this._logger.push({ res }).log('Bulk quote request sent to peer'); + } + catch (err) { + // cancel the timout and unsubscribe before rejecting the promise + clearTimeout(timeout); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkQuoteKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in error handler) ${bulkQuoteKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + } + }); + } + + /** + * Constructs a bulk quote request payload based on current state + * + * @returns {object} - the bulk quote request object + */ + _buildBulkQuoteRequest() { + const bulkQuoteRequest = { + bulkQuoteId: this.data.bulkQuoteId, + payer: shared.internalPartyToMojaloopParty(this.data.from, this._dfspId), + expiration: this._getExpirationTimestamp(), + }; + + this.data.geoCode && (bulkQuoteRequest.geoCode = this.data.geoCode); + + if (this.data.extensions && this.data.extensions.length > 0) { + bulkQuoteRequest.extensionList = { + extension: this.data.extensions + }; + } + + bulkQuoteRequest.individualQuotes = this.data.individualQuotes.map((individualQuote) => { + const quoteId = individualQuote.quoteId || uuid(); + const quote = { + quoteId: quoteId, + transactionId: individualQuote.transactionId || quoteId, + payee: shared.internalPartyToMojaloopParty(individualQuote.to, individualQuote.to.fspId), + amountType: individualQuote.amountType, + amount: { + currency: individualQuote.currency, + amount: individualQuote.amount + }, + transactionType: { + scenario: individualQuote.transactionType, + // TODO: support payee initiated txns? + initiator: 'PAYER', + // TODO: defaulting to CONSUMER initiator type should + // be replaced with a required element on the incoming + // API request + initiatorType: this.data.from.type || 'CONSUMER' + } + }; + + individualQuote.note && (quote.note = individualQuote.note); + + if (individualQuote.extensions && individualQuote.extensions.length > 0) { + bulkQuoteRequest.extensionList = { + extension: individualQuote.extensions + }; + } + + return quote; + }); + + return bulkQuoteRequest; + } + + /** + * Get bulk quote details by sending GET /bulkQuotes/{ID} request to the switch + */ + async _getBulkQuote(bulkQuoteId) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const bulkQuoteKey = `bulkQuote_${bulkQuoteId}`; + + // hook up a subscriber to handle response messages + const subId = await this._cache.subscribe(bulkQuoteKey, (cn, msg, subId) => { + try { + let error; + let message = JSON.parse(msg); + + if (message.type === 'bulkQuoteError') { + error = new BackendError(`Got an error response retrieving bulk quote: ${util.inspect(message.data, { depth: Infinity })}`, 500); + error.mojaloopError = message.data; + } else if (message.type !== 'bulkQuoteResponse') { + this._logger.push({ message }).log(`Ignoring cache notification for bulk quote ${bulkQuoteKey}. Uknokwn message type ${message.type}.`); + return; + } + + // cancel the timeout handler + clearTimeout(timeout); + + // stop listening for bulk quote response messages + this._cache.unsubscribe(bulkQuoteKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in callback) ${bulkQuoteKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + if (error) { + return reject(error); + } + + const bulkQuote = message.data; + this._logger.push({ bulkQuote }).log('Bulk quote response received'); + + return resolve(bulkQuote); + } + catch(err) { + return reject(err); + } + }); + + // set up a timeout for the resolution + const timeout = setTimeout(() => { + const err = new BackendError(`Timeout getting bulk quote ${bulkQuoteId}`, 504); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkQuoteKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in timeout handler) ${bulkQuoteKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + }, this._requestProcessingTimeoutSeconds * 1000); + + // now we have a timeout handler and a cache subscriber hooked up we can fire off + // a GET /bulkQuotes/{ID} request to the switch + try { + const res = await this._requests.getBulkQuotes(bulkQuoteId); + this._logger.push({ peer: res }).log('Bulk quote lookup sent to peer'); + } + catch(err) { + // cancel the timout and unsubscribe before rejecting the promise + clearTimeout(timeout); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkQuoteKey, subId).catch(e => { + this._logger.log(`Error unsubscribing ${bulkQuoteKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + } + }); + } + + /** + * Returns an ISO-8601 format timestamp n-seconds in the future for expiration of a bulk quote API object, + * where n is equal to our config setting "expirySeconds" + * + * @returns {string} - ISO-8601 format future expiration timestamp + */ + _getExpirationTimestamp() { + let now = new Date(); + return new Date(now.getTime() + (this._expirySeconds * 1000)).toISOString(); + } + + /** + * Returns an object representing the final state of the bulk quote suitable for the outbound API + * + * @returns {object} - Response representing the result of the bulk quoting process + */ + getResponse() { + // we want to project some of our internal state into a more useful + // representation to return to the SDK API consumer + let resp = { ...this.data }; + + switch(this.data.currentState) { + case 'succeeded': + resp.currentState = stateEnum.COMPLETED; + break; + + case 'errored': + resp.currentState = stateEnum.ERROR_OCCURRED; + break; + + default: + this._logger.log(`Bulk quote model response being returned from an unexpected state: ${this.data.currentState}. Returning ERROR_OCCURRED state`); + resp.currentState = stateEnum.ERROR_OCCURRED; + break; + } + + return resp; + } + + /** + * Persists the model state to cache for reinstantiation at a later point + */ + async _save() { + try { + this.data.currentState = this.stateMachine.state; + const res = await this._cache.set(`bulkQuoteModel_${this.data.bulkQuoteId}`, this.data); + this._logger.push({ res }).log('Persisted bulk quote model in cache'); + } + catch(err) { + this._logger.push({ err }).log('Error saving bulk quote model'); + throw err; + } + } + + /** + * Loads a bulk quote model from cache for resumption of the bulk quote process + * + * @param bulkQuoteId {string} - UUID bulkQuoteId of the model to load from cache + */ + async load(bulkQuoteId) { + try { + const data = await this._cache.get(`bulkQuoteModel_${bulkQuoteId}`); + if(!data) { + throw new Error(`No cached data found for bulkQuoteId: ${bulkQuoteId}`); + } + await this.initialize(data); + this._logger.push({ cache: this.data }).log('Bulk quote model loaded from cached state'); + } + catch(err) { + this._logger.push({ err }).log('Error loading bulk quote model'); + throw err; + } + } + + /** + * Returns a promise that resolves when the state machine has reached a terminal state + */ + async run() { + try { + // run transitions based on incoming state + switch(this.data.currentState) { + case 'start': + await this.stateMachine.requestBulkQuote(); + this._logger.log(`Quotes resolved for bulk quote ${this.data.bulkQuoteId}`); + break; + + case 'getBulkQuote': + await this.stateMachine.getBulkQuote(); + this._logger.log(`Get bulk quote ${this.data.bulkQuoteId} has been completed`); + break; + + case 'succeeded': + // all steps complete so return + this._logger.log('Bulk quoting completed successfully'); + await this._save(); + return this.getResponse(); + + case 'errored': + // stopped in errored state + this._logger.log('State machine in errored state'); + return; + } + + // now call ourselves recursively to deal with the next transition + this._logger.log(`Bulk quote model state machine transition completed in state: ${this.stateMachine.state}. Recursing to handle next transition.`); + return this.run(); + } + catch(err) { + this._logger.log(`Error running bulk quote model: ${util.inspect(err)}`); + + // as this function is recursive, we dont want to error the state machine multiple times + if(this.data.currentState !== 'errored') { + // err should not have a bulkQuoteState property here! + if(err.bulkQuoteState) { + this._logger.log(`State machine is broken: ${util.inspect(err)}`); + } + // transition to errored state + await this.stateMachine.error(err); + + // avoid circular ref between bulkQuoteState.lastError and err + err.bulkQuoteState = JSON.parse(JSON.stringify(this.getResponse())); + } + throw err; + } + } +} + + +module.exports = OutboundBulkQuotesModel; diff --git a/src/lib/model/OutboundBulkTransfersModel.js b/src/lib/model/OutboundBulkTransfersModel.js new file mode 100644 index 000000000..f7c1220c8 --- /dev/null +++ b/src/lib/model/OutboundBulkTransfersModel.js @@ -0,0 +1,476 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Steven Oderayi - steven.oderayi@modusbox.com * + **************************************************************************/ + +'use strict'; + +const util = require('util'); +const { uuid } = require('uuidv4'); +const StateMachine = require('javascript-state-machine'); +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { BackendError } = require('./common'); + +const stateEnum = { + 'ERROR_OCCURRED': 'ERROR_OCCURRED', + 'COMPLETED': 'COMPLETED', +}; + +/** + * Models the state machine and operations required for performing an outbound bulk transfer + */ +class OutboundBulkTransfersModel { + constructor(config) { + this._cache = config.cache; + this._logger = config.logger; + this._requestProcessingTimeoutSeconds = config.requestProcessingTimeoutSeconds; + this._dfspId = config.dfspId; + this._expirySeconds = config.expirySeconds; + this._rejectExpiredTransferFulfils = config.rejectExpiredTransferFulfils; + + this._requests = new MojaloopRequests({ + logger: this._logger, + peerEndpoint: config.peerEndpoint, + bulkTransfersEndpoint: config.bulkTransfersEndpoint, + dfspId: config.dfspId, + tls: config.tls, + jwsSign: config.jwsSign, + jwsSignPutParties: config.jwsSignPutParties, + jwsSigningKey: config.jwsSigningKey, + wso2Auth: config.wso2Auth + }); + } + + /** + * Initializes the internal state machine object + */ + _initStateMachine (initState) { + this.stateMachine = new StateMachine({ + init: initState, + transitions: [ + { name: 'executeBulkTransfer', from: 'start', to: 'succeeded' }, + { name: 'getBulkTransfer', to: 'succeeded' }, + { name: 'error', from: '*', to: 'errored' }, + ], + methods: { + onTransition: this._handleTransition.bind(this), + onAfterTransition: this._afterTransition.bind(this), + onPendingTransition: (transition, from, to) => { + // allow transitions to 'error' state while other transitions are in progress + if(transition !== 'error') { + throw new Error(`Transition requested while another transition is in progress: ${transition} from: ${from} to: ${to}`); + } + } + } + }); + + return this.stateMachine[initState]; + } + + /** + * Updates the internal state representation to reflect that of the state machine itself + */ + _afterTransition() { + this._logger.log(`State machine transitioned: ${this.data.currentState} -> ${this.stateMachine.state}`); + this.data.currentState = this.stateMachine.state; + } + + /** + * Initializes the bulk transfer model + * + * @param data {object} - The inbound API POST /bulkTransfers request body + */ + async initialize(data) { + this.data = data; + + // add a bulkTransferId if one is not present e.g. on first submission + if(!this.data.hasOwnProperty('bulkTransferId')) { + this.data.bulkTransferId = uuid(); + } + + // initialize the bulk transfer state machine to its starting state + if(!this.data.hasOwnProperty('currentState')) { + this.data.currentState = 'start'; + } + + this._initStateMachine(this.data.currentState); + } + + /** + * Handles state machine transitions + */ + async _handleTransition(lifecycle, ...args) { + this._logger.log(`Bulk transfer ${this.data.bulkTransferId} is transitioning from ${lifecycle.from} to ${lifecycle.to} in response to ${lifecycle.transition}`); + + switch(lifecycle.transition) { + case 'init': + return; + + case 'executeBulkTransfer': + return this._executeBulkTransfer(); + + case 'getBulkTransfer': + return this._getBulkTransfer(this.data.bulkTransferId); + + case 'error': + this._logger.log(`State machine is erroring with error: ${util.inspect(args)}`); + this.data.lastError = args[0] || new Error('unspecified error'); + break; + + default: + throw new Error(`Unhandled state transition for bulk transfer ${this.data.bulkTransferId}: ${util.inspect(args)}`); + } + } + + /** + * Executes a bulk transfer + * Starts the transfer process by sending a POST /bulkTransfers (prepare) request to the switch; + * then waits for a notification from the cache that the transfer has been fulfilled + */ + async _executeBulkTransfer() { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + // create a bulk transfer request + const bulkTransferPrepare = this._buildBulkTransferPrepareRequest(); + + // listen for events on the bulkTransferId + const bulkTransferKey = `bulkTransfer_${this.data.bulkTransferId}`; + + // hook up a subscriber to handle response messages + const subId = await this._cache.subscribe(bulkTransferKey, (cn, msg, subId) => { + try { + let error; + let message = JSON.parse(msg); + + if (message.type === 'bulkTransferFulfil') { + if (this._rejectExpiredTransferFulfils) { + const now = new Date().toISOString(); + if (now > bulkTransferPrepare.expiration) { + const msg = 'Bulk transfer fulfils missed expiry deadline'; + error = new BackendError(msg, 504); + this._logger.error(`${msg}: system time=${now} > expiration time=${bulkTransferPrepare.expiration}`); + } + } + } else if (message.type === 'bulkTransferError') { + error = new BackendError(`Got an error response preparing bulk transfer: ${util.inspect(message.data, { depth: Infinity })}`, 500); + error.mojaloopError = message.data; + } + else { + this._logger.push({ message }).log(`Ignoring cache notification for bulk transfer ${bulkTransferKey}. Unknown message type ${message.type}.`); + return; + } + + // cancel the timeout handler + clearTimeout(timeout); + + // stop listening for bulk transfer resolution messages + // no need to await for the unsubscribe to complete. + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkTransferKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in callback) ${bulkTransferKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + if (error) { + return reject(error); + } + + const bulkTransferFulfil = message.data; + this._logger.push({ bulkTransferFulfil }).log('Bulk transfer fulfils received'); + + return resolve(bulkTransferFulfil); + } + catch (err) { + return reject(err); + } + }); + + // set up a timeout for the request + const timeout = setTimeout(() => { + const err = new BackendError(`Timeout waiting for fulfil for bulk transfer ${this.data.bulkTransferId}`, 504); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkTransferKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in timeout handler) ${bulkTransferKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + }, this._requestProcessingTimeoutSeconds * 1000); + + // now we have a timeout handler and a cache subscriber hooked up we can fire off + // a POST /bulkTransfers request to the switch + try { + const res = await this._requests.postBulkTransfers(bulkTransferPrepare, this.data.payeeFsp.fspId); + this._logger.push({ res }).log('Bulk transfer request sent to peer'); + } + catch (err) { + // cancel the timout and unsubscribe before rejecting the promise + clearTimeout(timeout); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkTransferKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in error handler) ${bulkTransferKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + } + }); + } + + /** + * Constructs a bulk transfer request payload + * + * @returns {object} - the bulk transfer request object + */ + _buildBulkTransferPrepareRequest() { + const bulkTransferRequest = { + bulkTransferId: this.data.bulkTransferId, + bulkQuoteId: this.data.bulkQuoteId, + payerFsp: this._dfspId, + payeeFsp: this.data.payeeFsp.fspId, + expiration: this._getExpirationTimestamp() + }; + + // add extensionList if provided + if (this.data.extensions && this.data.extensions.length > 0) { + bulkTransferRequest.extensionList = { + extension: this.data.extensions + }; + } + + bulkTransferRequest.individualTransfers = this.data.individualTransfers.map((individualTransfer) => { + const transferId = individualTransfer.transferId || uuid(); + + const transferPrepare = { + transferId: transferId, + transferAmount: { + currency: individualTransfer.currency, + amount: individualTransfer.amount + }, + ilpPacket: individualTransfer.ilpPacket, + condition: individualTransfer.condition, + }; + + if (individualTransfer.extensions && individualTransfer.extensions.length > 0) { + bulkTransferRequest.extensionList = { + extension: individualTransfer.extensions + }; + } + + return transferPrepare; + }); + + return bulkTransferRequest; + } + + /** + * Get bulk transfer details by sending GET /bulkTransfers/{ID} request to the switch + */ + async _getBulkTransfer(bulkTransferId) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const bulkTransferKey = `bulkTransfer_${bulkTransferId}`; + + // hook up a subscriber to handle response messages + const subId = await this._cache.subscribe(bulkTransferKey, (cn, msg, subId) => { + try { + let error; + let message = JSON.parse(msg); + + if (message.type === 'bulkTransferError') { + error = new BackendError(`Got an error response retrieving bulk transfer: ${util.inspect(message.data, { depth: Infinity })}`, 500); + error.mojaloopError = message.data; + } else if (message.type !== 'bulkTransferFulfil') { + this._logger.push({ message }).log(`Ignoring cache notification for bulk transfer ${bulkTransferKey}. Uknokwn message type ${message.type}.`); + return; + } + + // cancel the timeout handler + clearTimeout(timeout); + + // stop listening for bulk transfer fulfil messages + this._cache.unsubscribe(bulkTransferKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in callback) ${bulkTransferKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + if (error) { + return reject(error); + } + + const fulfils = message.data; + this._logger.push({ fulfils }).log('Bulk transfer fulfils received'); + + return resolve(fulfils); + } + catch(err) { + return reject(err); + } + }); + + // set up a timeout for the resolution + const timeout = setTimeout(() => { + const err = new BackendError(`Timeout getting bulk transfer ${bulkTransferId}`, 504); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkTransferKey, subId).catch(e => { + this._logger.log(`Error unsubscribing (in timeout handler) ${bulkTransferKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + }, this._requestProcessingTimeoutSeconds * 1000); + + // now we have a timeout handler and a cache subscriber hooked up we can fire off + // a GET /bulkTransfers/{ID} request to the switch + try { + const res = await this._requests.getBulkTransfers(bulkTransferId); + this._logger.push({ peer: res }).log('Bulk transfer lookup sent to peer'); + } + catch(err) { + // cancel the timout and unsubscribe before rejecting the promise + clearTimeout(timeout); + + // we dont really care if the unsubscribe fails but we should log it regardless + this._cache.unsubscribe(bulkTransferKey, subId).catch(e => { + this._logger.log(`Error unsubscribing ${bulkTransferKey} ${subId}: ${e.stack || util.inspect(e)}`); + }); + + return reject(err); + } + }); + } + + /** + * Returns an ISO-8601 format timestamp n-seconds in the future for expiration of a bulk quote API object, + * where n is equal to our config setting "expirySeconds" + * + * @returns {string} - ISO-8601 format future expiration timestamp + */ + _getExpirationTimestamp() { + let now = new Date(); + return new Date(now.getTime() + (this._expirySeconds * 1000)).toISOString(); + } + + /** + * Returns an object representing the final state of the bulk transfer suitable for the outbound API + * + * @returns {object} - Response representing the result of the bulk transfer process + */ + getResponse() { + // we want to project some of our internal state into a more useful + // representation to return to the SDK API consumer + let resp = { ...this.data }; + + switch(this.data.currentState) { + case 'succeeded': + resp.currentState = stateEnum.COMPLETED; + break; + + case 'errored': + resp.currentState = stateEnum.ERROR_OCCURRED; + break; + + default: + this._logger.log(`Bulk transfer model response being returned from an unexpected state: ${this.data.currentState}. Returning ERROR_OCCURRED state`); + resp.currentState = stateEnum.ERROR_OCCURRED; + break; + } + + return resp; + } + + /** + * Persists the model state to cache for reinstantiation at a later point + */ + async _save() { + try { + this.data.currentState = this.stateMachine.state; + const res = await this._cache.set(`bulkTransferModel_${this.data.bulkTransferId}`, this.data); + this._logger.push({ res }).log('Persisted bulk transfer model in cache'); + } + catch(err) { + this._logger.push({ err }).log('Error saving bulk transfer model'); + throw err; + } + } + + + /** + * Loads a bulk transfer model from cache for resumption of the bulk transfer process + * + * @param bulkTransferId {string} - UUID bulkTransferId of the model to load from cache + */ + async load(bulkTransferId) { + try { + const data = await this._cache.get(`bulkTransferModel_${bulkTransferId}`); + if(!data) { + throw new Error(`No cached data found for bulkTransferId: ${bulkTransferId}`); + } + await this.initialize(data); + this._logger.push({ cache: this.data }).log('Bulk transfer model loaded from cached state'); + } + catch(err) { + this._logger.push({ err }).log('Error loading bulk transfer model'); + throw err; + } + } + + /** + * Returns a promise that resolves when the state machine has reached a terminal state + */ + async run() { + try { + // run transitions based on incoming state + switch(this.data.currentState) { + case 'start': + await this.stateMachine.executeBulkTransfer(); + this._logger.log(`Bulk transfer ${this.data.bulkTransferId} has been completed`); + break; + + case 'getBulkTransfer': + await this.stateMachine.getBulkTransfer(); + this._logger.log(`Get bulk transfer ${this.data.bulkTransferId} has been completed`); + break; + + case 'succeeded': + // all steps complete so return + this._logger.log('Bulk transfer completed successfully'); + await this._save(); + return this.getResponse(); + + case 'errored': + // stopped in errored state + this._logger.log('State machine in errored state'); + return; + } + + // now call ourselves recursively to deal with the next transition + this._logger.log(`Bulk transfer model state machine transition completed in state: ${this.stateMachine.state}. Recursing to handle next transition.`); + return this.run(); + } + catch(err) { + this._logger.log(`Error running transfer model: ${util.inspect(err)}`); + + // as this function is recursive, we dont want to error the state machine multiple times + if(this.data.currentState !== 'errored') { + // err should not have a bulkTransferState property here! + if(err.bulkTransferState) { + this._logger.log(`State machine is broken: ${util.inspect(err)}`); + } + // transition to errored state + await this.stateMachine.error(err); + + // avoid circular ref between bulkTransferState.lastError and err + err.bulkTransferState = JSON.parse(JSON.stringify(this.getResponse())); + } + throw err; + } + } + +} + + +module.exports = OutboundBulkTransfersModel; diff --git a/src/lib/model/index.js b/src/lib/model/index.js index a04caa8fc..473e6c103 100644 --- a/src/lib/model/index.js +++ b/src/lib/model/index.js @@ -13,6 +13,8 @@ const InboundTransfersModel = require('./InboundTransfersModel.js'); const OutboundTransfersModel = require('./OutboundTransfersModel.js'); +const OutboundBulkQuotesModel = require('./OutboundBulkQuotesModel'); +const OutboundBulkTransfersModel = require('./OutboundBulkTransfersModel.js'); const OutboundRequestToPayTransferModel = require('./OutboundRequestToPayTransferModel.js'); const AccountsModel = require('./AccountsModel'); const ProxyModel = require('./ProxyModel'); @@ -24,15 +26,17 @@ const { BackendError, PersistentStateMachine } = require('./common'); module.exports = { - InboundTransfersModel, - OutboundTransfersModel, - OutboundRequestToPayTransferModel, AccountsModel, - ProxyModel, BackendError, + OutboundBulkQuotesModel, + OutboundBulkTransfersModel, + OutboundRequestToPayTransferModel, OutboundRequestToPayModel, + InboundTransfersModel, + OutboundTransfersModel, + ProxyModel, OutboundAuthorizationsModel, PersistentStateMachine, InboundThirdpartyTransactionModel, - OutboundThirdpartyTransactionModel + OutboundThirdpartyTransactionModel, }; diff --git a/src/lib/model/lib/requests/backendRequests.js b/src/lib/model/lib/requests/backendRequests.js index ba5240eec..c7905fc7f 100644 --- a/src/lib/model/lib/requests/backendRequests.js +++ b/src/lib/model/lib/requests/backendRequests.js @@ -107,6 +107,43 @@ class BackendRequests { return this._post('transactionrequests', transactionRequest); } + /** + * Executes a POST /bulkQuotes request for the specified bulk quotes request + * + * @returns {object} - JSON response body if one was received + */ + async postBulkQuotes(bulkQuotesRequest) { + return this._post('bulkQuotes', bulkQuotesRequest); + } + + /** + * Executes a GET /bulkQuotes/{ID} request for the specified bulk quote ID + * + * @returns {object} - JSON response body if one was received + */ + async getBulkQuotes(bulkQuoteId) { + const url = `bulkQuotes/${bulkQuoteId}`; + return this._get(url); + } + + /** + * Executes a POST /bulkTransfers request for the specified bulk transfer prepare + * + * @returns {object} - JSON response body if one was received + */ + async postBulkTransfers(prepare) { + return this._post('bulktransfers', prepare); + } + + /** + * Executes a GET /bulkTransfers/{ID} request for the specified bulk transfer ID + * + * @returns {object} - JSON response body if one was received + */ + async getBulkTransfers(bulkTransferId) { + const url = `bulkTransfers/${bulkTransferId}`; + return this._get(url); + } /** * Utility function for building outgoing request headers as required by the mojaloop api spec diff --git a/src/lib/model/lib/shared/index.js b/src/lib/model/lib/shared/index.js index 3993ed7ff..7a243eacb 100644 --- a/src/lib/model/lib/shared/index.js +++ b/src/lib/model/lib/shared/index.js @@ -24,8 +24,7 @@ const internalPartyToMojaloopParty = (internal, fspId) => { partySubIdOrType: internal.idSubValue, fspId: fspId } - }; - + }; if (internal.extensionList) { party.partyIdInfo.extensionList = { extension: internal.extensionList @@ -285,6 +284,170 @@ const mojaloopTransactionRequestToInternal = (external) => { return internal; }; +/** + * Projects a Mojaloop API spec bulk quote request to internal form + * + * @returns {object} - the internal form bulk quote request + */ +const mojaloopBulkQuotesRequestToInternal = (external) => { + const internal = { + bulkQuoteId: external.bulkQuoteId, + from: mojaloopPartyToInternalParty(external.payer), + }; + + if (external.geoCode) { + internal.geoCode = external.geoCode; + } + + if (external.expiration) { + internal.expiration = external.expiration; + } + + if (external.extensionList) { + internal.extensionList = external.extensionList.extension; + } + + const internalIndividualQuotes = external.individualQuotes.map(quote => { + const internalQuote = { + quoteId: quote.quoteId, + transactionId: quote.transactionId, + to: mojaloopPartyToInternalParty(quote.payee), + amountType: quote.amountType, + amount: quote.amount.amount, + currency: quote.amount.currency, + transactionType: quote.transactionType.scenario, + initiator: quote.transactionType.initiator, + initiatorType: quote.transactionType.initiatorType + }; + + if (quote.fees) { + internal.feesAmount = quote.fees.amount; + internal.feesCurrency = quote.fees.currency; + } + + if (quote.geoCode) { + internal.geoCode = quote.geoCode; + } + + if (quote.note) { + internal.note = quote.note; + } + + return internalQuote; + }); + + internal.individualQuotes = internalIndividualQuotes; + + return internal; +}; + +/** + * Converts an internal bulk quotes response to mojaloop form + * + * @returns {object} + */ +const internalBulkQuotesResponseToMojaloop = (internal) => { + const individualQuoteResults = internal.individualQuotes.map((quote) => { + const externalQuote = { + quoteId: quote.quoteId, + transferAmount: { + amount: quote.transferAmount, + currency: quote.transferAmountCurrency, + }, + ilpPacket: quote.ilpPacket, + condition: quote.ilpCondition + }; + + if (quote.payeeReceiveAmount) { + externalQuote.payeeReceiveAmount = { + amount: quote.payeeReceiveAmount, + currency: quote.payeeReceiveAmountCurrency + }; + } + + if (quote.payeeFspFeeAmount) { + externalQuote.payeeFspFee = { + amount: quote.payeeFspFeeAmount, + currency: quote.payeeFspFeeAmountCurrency + }; + } + + if (quote.payeeFspCommissionAmount) { + externalQuote.payeeFspCommission = { + amount: quote.payeeFspCommissionAmount, + currency: quote.payeeFspCommissionAmountCurrency + }; + } + + return externalQuote; + }); + const external = { + individualQuoteResults, + expiration: internal.expiration, + }; + + if (internal.geoCode) { + external.geoCode = internal.geoCode; + } + + if (internal.extensionList) { + external.extensionList = internal.extensionList; + } + + return external; +}; + +/** + * Converts a mojaloop bulk transfer prepare request to internal form + * + * @returns {object} + */ +const mojaloopBulkPrepareToInternalBulkTransfer = (external, bulkQuotes, ilp) => { + let internal = null; + if (bulkQuotes) { + // create a map of internal individual quotes payees indexed by quotedId, for faster lookup + const internalQuotesPayeesByQuoteId = {}; + + for (const quote of bulkQuotes.internalRequest.individualQuotes) { + internalQuotesPayeesByQuoteId[quote.quoteId] = quote.to; + } + + // create a map of external individual transfers indexed by quotedId, for faster lookup + const externalTransferIdsByQuoteId = {}; + + for (const transfer of external.individualTransfers) { + const transactionObject = ilp.getTransactionObject(transfer.ilpPacket); + externalTransferIdsByQuoteId[transactionObject.quoteId] = transfer.transferId; + } + + internal = { + bulkTransferId: external.bulkTransferId, + bulkQuotes: bulkQuotes.response, + from: bulkQuotes.internalRequest.from, + }; + + internal.individualTransfers = bulkQuotes.request.individualQuotes.map((quote) => ({ + transferId: externalTransferIdsByQuoteId[quote.quoteId], + to: internalQuotesPayeesByQuoteId[quote.quoteId], + amountType: quote.amountType, + currency: quote.amount.currency, + amount: quote.amount.amount, + transactionType: quote.transactionType.scenario, + note: quote.note + })); + } else { + internal = { + bulkTransferId: external.bulkTransferId, + individualTransfers: external.individualTransfers.map((transfer) => ({ + transferId: transfer.transferId, + currency: transfer.amount.currency, + amount: transfer.amount.amount, + })) + }; + } + + return internal; +}; /** * Converts a mojaloop authorizationsReq data to internal form * @@ -326,5 +489,8 @@ module.exports = { mojaloopPrepareToInternalTransfer, mojaloopTransactionRequestToInternal, mojaloopAuthorizationsReqToInternal, - internalAuthorizationsResponseToMojaloop + internalAuthorizationsResponseToMojaloop, + mojaloopBulkQuotesRequestToInternal, + internalBulkQuotesResponseToMojaloop, + mojaloopBulkPrepareToInternalBulkTransfer, }; diff --git a/src/package.json b/src/package.json index e00f788c3..4c20534a6 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@mojaloop/sdk-scheme-adapter", - "version": "10.1.5", + "version": "10.5.1", "description": "An adapter for connecting to Mojaloop API enabled switches.", "main": "index.js", "scripts": { @@ -10,6 +10,9 @@ "test:int": "jest --ci --reporters=default --reporters=jest-junit --env=node test/integration --forceExit" }, "author": "Matt Kingston, James Bush, ModusBox Inc.", + "contributors": [ + "Steven Oderayi " + ], "license": "Apache-2.0", "licenses": [ { @@ -26,7 +29,7 @@ "@internal/router": "file:lib/router", "@internal/shared": "file:lib/model/lib/shared", "@internal/validate": "file:lib/validate", - "@mojaloop/sdk-standard-components": "^10.2.2", + "@mojaloop/sdk-standard-components": "^10.2.3", "ajv": "^6.12.2", "co-body": "^6.0.0", "dotenv": "^8.2.0", diff --git a/src/test/__mocks__/@internal/requests.js b/src/test/__mocks__/@internal/requests.js index bed9af352..aa4bf4ab9 100644 --- a/src/test/__mocks__/@internal/requests.js +++ b/src/test/__mocks__/@internal/requests.js @@ -29,6 +29,10 @@ class MockBackendRequests extends BackendRequests { this.postQuoteRequests = MockBackendRequests.__postQuoteRequests; this.getTransfers = MockBackendRequests.__getTransfers; this.postTransfers = MockBackendRequests.__postTransfers; + this.getBulkQuotes = MockBackendRequests.__getBulkQuotes; + this.postBulkQuotes = MockBackendRequests.__postBulkQuotes; + this.getBulkTransfers = MockBackendRequests.__getBulkTransfers; + this.postBulkTransfers = MockBackendRequests.__postBulkTransfers; } } MockBackendRequests.__getParties = jest.fn(() => Promise.resolve({body: {}})); @@ -38,6 +42,10 @@ MockBackendRequests.__postTransactionRequests = jest.fn(() => Promise.resolve({b MockBackendRequests.__postQuoteRequests = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__getTransfers = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__postTransfers = jest.fn(() => Promise.resolve({body: {}})); +MockBackendRequests.__getBulkQuotes = jest.fn(() => Promise.resolve({body: {}})); +MockBackendRequests.__postBulkQuotes = jest.fn(() => Promise.resolve({body: {}})); +MockBackendRequests.__getBulkTransfers = jest.fn(() => Promise.resolve({body: {}})); +MockBackendRequests.__postBulkTransfers = jest.fn(() => Promise.resolve({body: {}})); class HTTPResponseError extends Error { diff --git a/src/test/__mocks__/@mojaloop/sdk-standard-components.js b/src/test/__mocks__/@mojaloop/sdk-standard-components.js index f8eed42bb..4bda12dd0 100644 --- a/src/test/__mocks__/@mojaloop/sdk-standard-components.js +++ b/src/test/__mocks__/@mojaloop/sdk-standard-components.js @@ -33,6 +33,14 @@ class MockMojaloopRequests extends MojaloopRequests { this.postTransfers = MockMojaloopRequests.__postTransfers; this.putTransfers = MockMojaloopRequests.__putTransfers; this.putTransfersError = MockMojaloopRequests.__putTransfersError; + this.getBulkQuotes = MockMojaloopRequests.__getBulkQuotes; + this.postBulkQuotes = MockMojaloopRequests.__postBulkQuotes; + this.putBulkQuotes = MockMojaloopRequests.__putBulkQuotes; + this.putBulkQuotesError = MockMojaloopRequests.__putBulkQuotesError; + this.getBulkTransfers = MockMojaloopRequests.__getBulkTransfers; + this.postBulkTransfers = MockMojaloopRequests.__postBulkTransfers; + this.putBulkTransfers = MockMojaloopRequests.__putBulkTransfers; + this.putBulkTransfersError = MockMojaloopRequests.__putBulkTransfersError; } } MockMojaloopRequests.__postParticipants = jest.fn(() => Promise.resolve()); @@ -49,6 +57,15 @@ MockMojaloopRequests.__putTransactionRequests = jest.fn(() => Promise.resolve()) MockMojaloopRequests.__postTransfers = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__putTransfers = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__putTransfersError = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__getBulkQuotes = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__postBulkQuotes = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__putBulkQuotes = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__putBulkQuotesError = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__getBulkTransfers = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__postBulkTransfers = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__putBulkTransfers = jest.fn(() => Promise.resolve()); +MockMojaloopRequests.__putBulkTransfersError = jest.fn(() => Promise.resolve()); + class MockIlp { constructor(config) { @@ -82,6 +99,13 @@ class MockIlp { return this.getResponseIlp(...args); } + + + getTransactionObject(...args) { + console.log(`MockIlp.getTrasnactionObject called with args: ${util.inspect(args)}`); + + return MockIlp.__transactionObject; + } } MockIlp.__response = { fulfilment: 'mockGeneratedFulfilment', @@ -89,6 +113,10 @@ MockIlp.__response = { condition: 'mockGeneratedCondition' }; +MockIlp.__transactionObject = { + transactionId: 'mockTransactionId' +}; + class MockJwsValidator extends Jws.validator { constructor(config) { diff --git a/src/test/config/integration.env b/src/test/config/integration.env index 07af45edf..850619221 100644 --- a/src/test/config/integration.env +++ b/src/test/config/integration.env @@ -54,6 +54,7 @@ PEER_ENDPOINT=172.17.0.3:4000 #ALS_ENDPOINT=account-lookup-service.local #QUOTES_ENDPOINT=quoting-service.local #TRANSFERS_ENDPOINT=ml-api-adapter.local +#BULK_TRANSFERS_ENDPOINT=bulk-api-adapter.local # BACKEND ENDPOINT BACKEND_ENDPOINT=172.17.0.5:4000 diff --git a/src/test/unit/inboundApi/data/mockArguments.json b/src/test/unit/inboundApi/data/mockArguments.json index 6a75bdc31..88dcfc348 100644 --- a/src/test/unit/inboundApi/data/mockArguments.json +++ b/src/test/unit/inboundApi/data/mockArguments.json @@ -24,5 +24,94 @@ "name": "Murthy Kakarlamudi", "merchantClassificationCode": "123" } + }, + "bulkQuoteRequest": { + "bulkQuoteId": "fake-bulk-quote-id", + "payer": { + "partyIdInfo": { + "partyIdType": "MSISDN", + "partyIdentifier": "17855501914", + "fspId": "mojaloop-sdk" + }, + "personalInfo": { + "complexName": { + "firstName": "Donald", + "lastName": "Trump" + }, + "dateOfBirth": "2010-10-10" + }, + "name": "Donald Trump", + "merchantClassificationCode": "123" + }, + "individualQuotes": [ + { + "quoteId": "fake-bulk-quote-id", + "transactionId": "fake-transaction-id", + "payee": { + "partyIdInfo": { + "partyIdType": "MSISDN", + "partyIdentifier": "17855508275", + "fspId": "mojaloop-sdk" + }, + "personalInfo": { + "complexName": { + "firstName": "Justin", + "lastName": "Trudeau" + }, + "dateOfBirth": "2010-10-10" + }, + "name": "Justin Trudeau", + "merchantClassificationCode": "123" + }, + "amountType": "SEND", + "amount": { + "currency": "XOF", + "amount": 10 + }, + "transactionType": { + "scenario": "TRANSFER", + "initiator": "PAYER", + "initiatorType": "CONSUMER" + } + } + ], + "expiration": "2019-06-04T04:02:10.378Z" + }, + "bulkQuotePutRequest": { + "expiration": "2019-06-04T04:02:10.378Z", + "individualQuoteResults": [ + { + "quoteId": "fake-bulk-transfer-id", + "ilpPacket": "WLctttbu2HvTsa1XWvUoGRcQozHsqeu9Ahl2JW9Bsu8", + "condition": "fake-condition" + } + ] + }, + "bulkTransferRequest": { + "bulkTransferId": "fake-bulk-transfer-id", + "bulkQuoteId": "fake-bulk-quote-id", + "payerFsp": "fake-payer-fsp", + "payeeFsp": "fake-payee-fsp", + "expiration": "2019-06-04T04:02:10.378Z", + "individualTransfers": [ + { + "transferId": "fake-bulk-transfer-id", + "transferAmount": { + "currency": "XOF", + "amount": 10 + }, + "ilpPacket": "AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA", + "condition": "f5sqb7tBTWPd5Y8BDFdMm9BJR_MNI4isf8p8n4D5pHA" + } + ] + }, + "bulkTransferPutRequest": { + "completedTimestamp": "2019-06-04T04:02:10.378Z", + "individualTransferResults": [ + { + "transferId": "fake-bulk-transfer-id", + "fulfilment": "WLctttbu2HvTsa1XWvUoGRcQozHsqeu9Ahl2JW9Bsu8" + } + ] } -} +} \ No newline at end of file diff --git a/src/test/unit/inboundApi/handlers.test.js b/src/test/unit/inboundApi/handlers.test.js index b620c2417..4b524cb98 100644 --- a/src/test/unit/inboundApi/handlers.test.js +++ b/src/test/unit/inboundApi/handlers.test.js @@ -24,7 +24,6 @@ const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsMod const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; const ThirdpartyTrxnModelOut = require('@internal/model').OutboundThirdpartyTransactionModel; - describe('Inbound API handlers:', () => { let mockArgs; let mockTransactionRequest; @@ -71,6 +70,320 @@ describe('Inbound API handlers:', () => { }); + describe('POST /bulkQuotes', () => { + + let mockContext; + + beforeEach(() => { + mockContext = { + request: { + body: mockArgs.bulkQuoteRequest, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) + } + }; + + }); + + test('calls `model.bulkQuoteRequest` with the expected arguments.', async () => { + const bulkQuoteRequestSpy = jest.spyOn(Model.prototype, 'bulkQuoteRequest'); + + await expect(handlers['/bulkQuotes'].post(mockContext)).resolves.toBe(undefined); + + expect(bulkQuoteRequestSpy).toHaveBeenCalledTimes(1); + expect(bulkQuoteRequestSpy.mock.calls[0][0]).toBe(mockContext.request.body); + expect(bulkQuoteRequestSpy.mock.calls[0][1]).toBe(mockContext.request.headers['fspiop-source']); + }); + }); + + describe('PUT /bulkQuotes/{ID}', () => { + + let mockContext; + + beforeEach(() => { + + mockContext = { + request: { + body: mockArgs.bulkQuotePutRequest, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234567890' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), + cache: { + publish: async () => Promise.resolve(true) + } + } + }; + }); + + test('calls `ctx.state.cache.publish` with the expected arguments.', async () => { + const bulkQuotesSpy = jest.spyOn(mockContext.state.cache, 'publish'); + + await expect(handlers['/bulkQuotes/{ID}'].put(mockContext)).resolves.toBe(undefined); + expect(mockContext.response.status).toBe(200); + expect(bulkQuotesSpy).toHaveBeenCalledTimes(1); + expect(bulkQuotesSpy.mock.calls[0][1]).toMatchObject({ + type: 'bulkQuoteResponse', + data: mockContext.request.body, + headers: mockContext.request.headers + }); + }); + }); + + describe('PUT /bulkQuotes/{ID}/error', () => { + + let mockContext; + + beforeEach(() => { + + mockContext = { + request: { + body: { + errorInformation: { + errorCode: '5100', + errorDescription: 'Fake error' + } + }, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234567890' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), + cache: { + publish: async () => Promise.resolve(true) + } + } + }; + }); + + test('calls `ctx.state.cache.publish` with the expected arguments.', async () => { + const bulkQuotesSpy = jest.spyOn(mockContext.state.cache, 'publish'); + + await expect(handlers['/bulkQuotes/{ID}/error'].put(mockContext)).resolves.toBe(undefined); + expect(mockContext.response.status).toBe(200); + expect(bulkQuotesSpy).toHaveBeenCalledTimes(1); + expect(bulkQuotesSpy.mock.calls[0][1]).toMatchObject({ + type: 'bulkQuoteResponseError', + data: mockContext.request.body + }); + }); + }); + + describe('GET /bulkQuotes/{ID}', () => { + + let mockContext; + + beforeEach(() => { + + mockContext = { + request: { + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234567890' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) + } + }; + }); + + test('calls `model.getBulkQuote` with the expected arguments.', async () => { + const bulkQuotesSpy = jest.spyOn(Model.prototype, 'getBulkQuote'); + + await expect(handlers['/bulkQuotes/{ID}'].get(mockContext)).resolves.toBe(undefined); + + expect(bulkQuotesSpy).toHaveBeenCalledTimes(1); + expect(bulkQuotesSpy.mock.calls[0][1]).toBe(mockContext.request.headers['fspiop-source']); + }); + }); + + describe('POST /bulkTransfers', () => { + + let mockContext; + + beforeEach(() => { + mockContext = { + request: { + body: mockArgs.bulkTransferRequest, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) + } + }; + + }); + + test('calls `model.prepareBulkTransfer` with the expected arguments.', async () => { + const bulkTransferRequestSpy = jest.spyOn(Model.prototype, 'prepareBulkTransfer'); + + await expect(handlers['/bulkTransfers'].post(mockContext)).resolves.toBe(undefined); + + expect(bulkTransferRequestSpy).toHaveBeenCalledTimes(1); + expect(bulkTransferRequestSpy.mock.calls[0][0]).toBe(mockContext.request.body); + expect(bulkTransferRequestSpy.mock.calls[0][1]).toBe(mockContext.request.headers['fspiop-source']); + }); + }); + + describe('PUT /bulkTransfers/{ID}', () => { + + let mockContext; + + beforeEach(() => { + + mockContext = { + request: { + body: mockArgs.bulkTransferPutRequest, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234567890' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), + cache: { + publish: async () => Promise.resolve(true) + } + } + }; + }); + + test('calls `ctx.state.cache.publish` with the expected arguments.', async () => { + const bulkTransfersSpy = jest.spyOn(mockContext.state.cache, 'publish'); + + await expect(handlers['/bulkTransfers/{ID}'].put(mockContext)).resolves.toBe(undefined); + expect(mockContext.response.status).toBe(200); + expect(bulkTransfersSpy).toHaveBeenCalledTimes(1); + expect(bulkTransfersSpy.mock.calls[0][1]).toMatchObject({ + type: 'bulkTransferResponse', + data: mockContext.request.body, + headers: mockContext.request.headers + }); + }); + }); + + describe('PUT /bulkTransfers/{ID}/error', () => { + + let mockContext; + + beforeEach(() => { + + mockContext = { + request: { + body: { + errorInformation: { + errorCode: '5100', + errorDescription: 'Fake error' + } + }, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234567890' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), + cache: { + publish: async () => Promise.resolve(true) + } + } + }; + }); + + test('calls `ctx.state.cache.publish` with the expected arguments.', async () => { + const bulkTransfersSpy = jest.spyOn(mockContext.state.cache, 'publish'); + + await expect(handlers['/bulkTransfers/{ID}/error'].put(mockContext)).resolves.toBe(undefined); + expect(mockContext.response.status).toBe(200); + expect(bulkTransfersSpy).toHaveBeenCalledTimes(1); + expect(bulkTransfersSpy.mock.calls[0][1]).toMatchObject({ + type: 'bulkTransferResponseError', + data: mockContext.request.body + }); + }); + }); + + describe('GET /bulkTransfers/{ID}', () => { + + let mockContext; + + beforeEach(() => { + + mockContext = { + request: { + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234567890' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }) + } + }; + }); + + test('calls `model.getBulkTransfer` with the expected arguments.', async () => { + const bulkTransfersSpy = jest.spyOn(Model.prototype, 'getBulkTransfer'); + + await expect(handlers['/bulkTransfers/{ID}'].get(mockContext)).resolves.toBe(undefined); + + expect(bulkTransfersSpy).toHaveBeenCalledTimes(1); + expect(bulkTransfersSpy.mock.calls[0][1]).toBe(mockContext.request.headers['fspiop-source']); + }); + }); + describe('POST /transactionRequests', () => { let mockTransactionReqContext; diff --git a/src/test/unit/lib/model/InboundTransfersModel.test.js b/src/test/unit/lib/model/InboundTransfersModel.test.js index 5ab1719b1..34f61580c 100644 --- a/src/test/unit/lib/model/InboundTransfersModel.test.js +++ b/src/test/unit/lib/model/InboundTransfersModel.test.js @@ -24,6 +24,8 @@ const Cache = require('@internal/cache'); const getTransfersBackendResponse = require('./data/getTransfersBackendResponse'); const getTransfersMojaloopResponse = require('./data/getTransfersMojaloopResponse'); +const getBulkTransfersBackendResponse = require('./data/getBulkTransfersBackendResponse'); +const getBulkTransfersMojaloopResponse = require('./data/getBulkTransfersMojaloopResponse'); describe('inboundModel', () => { let config; @@ -107,6 +109,67 @@ describe('inboundModel', () => { }); + }); + + describe('bulkQuoteRequest', () => { + let expectedQuoteResponseILP; + let model; + let cache; + + beforeEach(async () => { + expectedQuoteResponseILP = Ilp.__response; + BackendRequests.__postBulkQuotes = jest.fn().mockReturnValue(Promise.resolve(mockArgs.internalBulkQuoteResponse)); + + cache = new Cache({ + host: 'dummycachehost', + port: 1234, + logger, + }); + await cache.connect(); + + model = new Model({ + ...config, + cache, + logger, + }); + }); + + afterEach(async () => { + MojaloopRequests.__putBulkQuotes.mockClear(); + await cache.disconnect(); + }); + + test('calls `mojaloopRequests.putBulkQuotes` with the expected arguments.', async () => { + await model.bulkQuoteRequest(mockArgs.bulkQuoteRequest, mockArgs.fspId); + + expect(MojaloopRequests.__putBulkQuotes).toHaveBeenCalledTimes(1); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][1].expiration).toBe(mockArgs.internalBulkQuoteResponse.expiration); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][1].individualQuoteResults[0].ilpPacket).toBe(expectedQuoteResponseILP.ilpPacket); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][1].individualQuoteResults[0].condition).toBe(expectedQuoteResponseILP.condition); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][2]).toBe(mockArgs.fspId); + }); + + test('adds a custom `expiration` property in case it is not defined.', async() => { + // set a custom mock time in the global Date object in order to avoid race conditions. + // Make sure to clear it at the end of the test case. + const currentTime = new Date().getTime(); + const dateSpy = jest.spyOn(Date.prototype, 'getTime').mockImplementation(() => currentTime); + const expectedExpirationDate = new Date(currentTime + (config.expirySeconds * 1000)).toISOString(); + + delete mockArgs.internalBulkQuoteResponse.expiration; + + await model.bulkQuoteRequest(mockArgs.bulkQuoteRequest, mockArgs.fspId); + + expect(MojaloopRequests.__putBulkQuotes).toHaveBeenCalledTimes(1); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][1].expiration).toBe(expectedExpirationDate); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][1].individualQuoteResults[0].ilpPacket).toBe(expectedQuoteResponseILP.ilpPacket); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][1].individualQuoteResults[0].condition).toBe(expectedQuoteResponseILP.condition); + expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][2]).toBe(mockArgs.fspId); + + dateSpy.mockClear(); + }); + + }); describe('transactionRequest', () => { @@ -349,4 +412,187 @@ describe('inboundModel', () => { expect(MojaloopRequests.__putTransfers).toHaveBeenCalledTimes(1); }); }); + + describe('prepareBulkTransfer:', () => { + let cache; + + beforeEach(async () => { + MojaloopRequests.__putBulkTransfersError.mockClear(); + MojaloopRequests.__putBulkTransfers = jest.fn().mockReturnValue(Promise.resolve({})); + BackendRequests.__postBulkTransfers = jest.fn().mockReturnValue(Promise.resolve({})); + + cache = new Cache({ + host: 'dummycachehost', + port: 1234, + logger, + }); + await cache.connect(); + }); + + afterEach(async () => { + await cache.disconnect(); + }); + + test('fail on bulk quote `expiration` deadline.', async () => { + const BULK_TRANSFER_ID = 'fake-bulk-transfer-id'; + const BULK_QUOTE_ID = 'fake-bulk-quote-id'; + const model = new Model({ + ...config, + cache, + logger, + rejectTransfersOnExpiredQuotes: true, + }); + cache.set(`bulkQuotes_${BULK_QUOTE_ID}`, { + mojaloopResponse: { + expiration: new Date(new Date().getTime() - 1000).toISOString(), + individualQuoteResults: [], + } + }); + const args = { + bulkTransferId: BULK_TRANSFER_ID, + bulkQuoteId: BULK_QUOTE_ID, + individualTransfers: [], + }; + + await model.prepareBulkTransfer(args, mockArgs.fspId); + + expect(MojaloopRequests.__putBulkTransfersError).toHaveBeenCalledTimes(1); + const call = MojaloopRequests.__putBulkTransfersError.mock.calls[0]; + expect(call[0]).toEqual(BULK_TRANSFER_ID); + expect(call[1].errorInformation.errorCode).toEqual('3302'); + }); + + test('getBulkTransfer should return COMMITTED bulk transfer', async () => { + const BULK_TRANSFER_ID = 'fake-bulk-transfer-id'; + + const backendResponse = JSON.parse(JSON.stringify(getBulkTransfersBackendResponse)); + BackendRequests.__getBulkTransfers = jest.fn().mockReturnValue(Promise.resolve(backendResponse)); + + const model = new Model({ + ...config, + cache, + logger, + }); + + await model.getBulkTransfer(BULK_TRANSFER_ID, mockArgs.fspId); + + expect(MojaloopRequests.__putBulkTransfers).toHaveBeenCalledTimes(1); + const call = MojaloopRequests.__putBulkTransfers.mock.calls[0]; + expect(call[0]).toEqual(BULK_TRANSFER_ID); + expect(call[1]).toEqual(getBulkTransfersMojaloopResponse); + expect(call[1].bulkTransferState).toEqual('COMMITTED'); + }); + + test('getBulkTransfer should not return fulfillment from payer', async () => { + const BULK_TRANSFER_ID = 'fake-bulk-transfer-id'; + + const backendResponse = JSON.parse(JSON.stringify(getBulkTransfersBackendResponse)); + backendResponse.internalRequest.individualTransfers[0].to.fspId = 'payer-dfsp'; + BackendRequests.__getBulkTransfers = jest.fn().mockReturnValue(Promise.resolve(backendResponse)); + + const model = new Model({ + ...config, + cache, + logger, + }); + + await model.getBulkTransfer(BULK_TRANSFER_ID, mockArgs.fspId); + + const call = MojaloopRequests.__putBulkTransfers.mock.calls[0]; + expect(call[0]).toEqual(BULK_TRANSFER_ID); + expect(call[1].bulkTransferState).toEqual('COMMITTED'); + const expectedResponse = {...getBulkTransfersMojaloopResponse}; + expectedResponse.individualTransferResults[0].fulfilment = undefined; + expect(call[1]).toMatchObject(expectedResponse); + }); + + test('getBulkTransfer should return not found error', async () => { + const BULK_TRANSFER_ID = 'fake-bulk-transfer-id'; + + BackendRequests.__getBulkTransfers = jest.fn().mockReturnValue( + Promise.reject(new HTTPResponseError({ + res: { + body: JSON.stringify({ + statusCode: '3208' + }), + } + }))); + + const model = new Model({ + ...config, + cache, + logger, + }); + + await model.getBulkTransfer(BULK_TRANSFER_ID, mockArgs.fspId); + + expect(MojaloopRequests.__putBulkTransfersError).toHaveBeenCalledTimes(1); + const call = MojaloopRequests.__putBulkTransfersError.mock.calls[0]; + expect(call[0]).toEqual(`${BULK_TRANSFER_ID}`); + expect(call[1].errorInformation.errorCode).toEqual('3208'); + }); + + test('fail on bulk transfer without bulk quote.', async () => { + const BULK_TRANSFER_ID = 'without_bulk-quote-bulk-transfer-id'; + const args = { + bulkTransferId: BULK_TRANSFER_ID, + ilpPacket: 'mockBase64encodedIlpPacket', + condition: 'mockGeneratedCondition', + individualTransfers: [ + { + amount: { + currency: 'USD', + amount: 20.13 + }, + } + ] + }; + + const model = new Model({ + ...config, + cache, + logger, + allowTransferWithoutQuote: false, + }); + + await model.prepareBulkTransfer(args, mockArgs.fspId); + + expect(MojaloopRequests.__putBulkTransfersError).toHaveBeenCalledTimes(1); + const call = MojaloopRequests.__putBulkTransfersError.mock.calls[0]; + expect(call[0]).toEqual(BULK_TRANSFER_ID); + expect(call[1].errorInformation.errorCode).toEqual('2001'); + }); + + test('pass on bulk transfer without bulk quote.', async () => { + const BULK_TRANSFER_ID = 'without_bulk-quote-bulk-transfer-id'; + const args = { + bulkTransferId: BULK_TRANSFER_ID, + individualTransfers: [ + { + transferId: 'fake-transfer-id', + amount: { + currency: 'USD', + amount: 20.13 + }, + ilpPacket: 'mockBase64encodedIlpPacket', + condition: 'mockGeneratedCondition', + } + ] + }; + + const model = new Model({ + ...config, + cache, + logger, + allowTransferWithoutQuote: true, + rejectTransfersOnExpiredQuotes: false, + }); + + await model.prepareBulkTransfer(args, mockArgs.fspId); + + expect(MojaloopRequests.__putBulkTransfersError).toHaveBeenCalledTimes(0); + expect(BackendRequests.__postBulkTransfers).toHaveBeenCalledTimes(1); + expect(MojaloopRequests.__putBulkTransfers).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/test/unit/lib/model/OutboundBulkQuotesModel.test.js b/src/test/unit/lib/model/OutboundBulkQuotesModel.test.js new file mode 100644 index 000000000..654370c71 --- /dev/null +++ b/src/test/unit/lib/model/OutboundBulkQuotesModel.test.js @@ -0,0 +1,256 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Steven Oderayi - steven.oderayi@modusbox.com * + **************************************************************************/ + +'use strict'; + +// we use a mock standard components lib to intercept and mock certain funcs +jest.mock('@mojaloop/sdk-standard-components'); +jest.mock('redis'); + +const util = require('util'); +const Cache = require('@internal/cache'); +const Model = require('@internal/model').OutboundBulkQuotesModel; +const { Logger, Transports } = require('@internal/log'); + +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const StateMachine = require('javascript-state-machine'); + +const defaultConfig = require('./data/defaultConfig'); +const bulkQuoteRequest = require('./data/bulkQuoteRequest'); +const bulkQuoteResponseTemplate = require('./data/bulkQuoteResponse'); + +// util function to simulate a quote response subscription message on a cache client +const emitBulkQuoteResponseCacheMessage = (cache, bulkQuoteId, bulkQuoteResponse) => { + cache.publish(`bulkQuote_${bulkQuoteId}`, JSON.stringify(bulkQuoteResponse)); +}; + +describe('OutboundBulkQuotesModel', () => { + let bulkQuoteResponse; + let config; + let logger; + let cache; + + /** + * + * @param {Object} opts + * @param {Number} opts.expirySeconds + * @param {Object} opts.delays + * @param {Number} delays.requestBulkQuotes + * @param {Object} opts.rejects + * @param {boolean} rejects.bulkQuoteResponse + */ + async function testBulkQuoteWithDelay({expirySeconds, delays, rejects}) { + const config = JSON.parse(JSON.stringify(defaultConfig)); + config.expirySeconds = expirySeconds; + config.rejectExpiredQuoteResponses = rejects.bulkQuoteResponse; + + // simulate a delayed callback with the bulk quote response + MojaloopRequests.__postBulkQuotes = jest.fn((postBulkQuotesBody) => { + setTimeout(() => { + emitBulkQuoteResponseCacheMessage(cache, postBulkQuotesBody.bulkQuoteId, bulkQuoteResponse); + }, delays.requestBulkQuotes ? delays.requestBulkQuotes * 1000 : 0); + }); + + const model = new Model({ + ...config, + cache, + logger, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); + + let expectError; + + if (rejects.bulkQuoteResponse && delays.requestBulkQuotes && expirySeconds < delays.requestBulkQuotes) { + expectError = 'Bulk quote response missed expiry deadline'; + } + + if (expectError) { + await expect(model.run()).rejects.toThrowError(expectError); + } else { + const result = await model.run(); + await expect(result.currentState).toBe('COMPLETED'); + } + } + + beforeAll(async () => { + const logTransports = await Promise.all([Transports.consoleDir()]); + logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + bulkQuoteResponse = JSON.parse(JSON.stringify(bulkQuoteResponseTemplate)); + }); + + beforeEach(async () => { + config = JSON.parse(JSON.stringify(defaultConfig)); + + MojaloopRequests.__postBulkQuotes = jest.fn(() => Promise.resolve()); + MojaloopRequests.__putBulkQuotes = jest.fn(() => Promise.resolve()); + MojaloopRequests.__putBulkQuotesError = jest.fn(() => Promise.resolve()); + + cache = new Cache({ + host: 'dummycachehost', + port: 1234, + logger, + }); + await cache.connect(); + }); + + afterEach(async () => { + await cache.disconnect(); + }); + + test('initializes to starting state', async () => { + const model = new Model({ + cache, + logger, + ...config, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); + expect(StateMachine.__instance.state).toBe('start'); + }); + + test('test get bulk quote', async () => { + MojaloopRequests.__getBulkQuotes = jest.fn((bulkQuoteId) => { + emitBulkQuoteResponseCacheMessage(cache, bulkQuoteId, bulkQuoteResponse); + return Promise.resolve(); + }); + + const model = new Model({ + cache, + logger, + ...config, + }); + + const BULK_QUOTE_ID = 'bq-id000011'; + + await model.initialize(JSON.parse(JSON.stringify({ + currentState: 'getBulkQuote', + bulkQuoteId: BULK_QUOTE_ID, + }))); + + expect(StateMachine.__instance.state).toBe('getBulkQuote'); + + // start the model running + const result = await model.run(); + + console.log(`Result after get bulk quote: ${util.inspect(result)}`); + + expect(MojaloopRequests.__getBulkQuotes).toHaveBeenCalledTimes(1); + + // check we stopped at succeeded state + expect(result.currentState).toBe('COMPLETED'); + expect(StateMachine.__instance.state).toBe('succeeded'); + }); + + test('sends bulk quotes request with correct payload', async () => { + MojaloopRequests.__postBulkQuotes = jest.fn((postBulkQuotesBody) => { + // ensure that the `MojaloopRequests.postBulkQuotes` method has been called with correct arguments + // including extension list + const extensionList = postBulkQuotesBody.extensionList.extension; + expect(extensionList).toBeTruthy(); + expect(extensionList.length).toBe(2); + expect(extensionList[0]).toEqual({ key: 'qkey1', value: 'qvalue1' }); + expect(extensionList[1]).toEqual({ key: 'qkey2', value: 'qvalue2' }); + + // simulate a callback with the bulk quote response + emitBulkQuoteResponseCacheMessage(cache, postBulkQuotesBody.bulkQuoteId, bulkQuoteResponse); + return Promise.resolve(); + }); + + const model = new Model({ + cache, + logger, + ...config, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); + + expect(StateMachine.__instance.state).toBe('start'); + + // start the model running + const result = await model.run(); + + console.log(`Result after bulk quote: ${util.inspect(result)}`); + + expect(MojaloopRequests.__postBulkQuotes).toHaveBeenCalledTimes(1); + + // check we stopped at 'succeeded' state + expect(result.currentState).toBe('COMPLETED'); + expect(StateMachine.__instance.state).toBe('succeeded'); + }); + + test('pass quote response `expiration` deadline', () => + testBulkQuoteWithDelay({ + expirySeconds: 2, + delays: { + requestBulkQuotes: 1, + }, + rejects: { + bulkQuoteResponse: true, + } + }) + ); + + test('fail on quote response `expiration` deadline', () => + testBulkQuoteWithDelay({ + expirySeconds: 1, + delays: { + requestBulkQuotes: 2, + }, + rejects: { + bulkQuoteResponse: true, + } + }) + ); + + test('Throws with mojaloop error in response body when quote request error callback occurs', async () => { + const expectError = { + type: 'bulkQuoteResponseError', + data: { + errorInformation: { + errorCode: '3205', + errorDescription: 'Bulk quote ID not found' + } + } + }; + + MojaloopRequests.__postBulkQuotes = jest.fn((postBulkQuotesBody) => { + // simulate a callback with the bulk quote response + cache.publish(`bulkQuote_${postBulkQuotesBody.bulkQuoteId}`, JSON.stringify(expectError)); + return Promise.resolve(); + }); + + const model = new Model({ + cache, + logger, + ...config, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); + + expect(StateMachine.__instance.state).toBe('start'); + + const errMsg = 'Got an error response requesting bulk quote: { errorInformation:\n { errorCode: \'3205\', errorDescription: \'Bulk quote ID not found\' } }'; + + try { + await model.run(); + } + catch(err) { + expect(err.message.replace(/[ \n]/g,'')).toEqual(errMsg.replace(/[ \n]/g,'')); + expect(err.bulkQuoteState).toBeTruthy(); + expect(err.bulkQuoteState.lastError).toBeTruthy(); + expect(err.bulkQuoteState.lastError.mojaloopError).toEqual(expectError.data); + expect(err.bulkQuoteState.lastError.bulkQuoteState).toBe(undefined); + return; + } + + throw new Error('Outbound bulk quotes model should have thrown'); + }); +}); diff --git a/src/test/unit/lib/model/OutboundBulkTransfersModel.test.js b/src/test/unit/lib/model/OutboundBulkTransfersModel.test.js new file mode 100644 index 000000000..4148d4936 --- /dev/null +++ b/src/test/unit/lib/model/OutboundBulkTransfersModel.test.js @@ -0,0 +1,251 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Steven Oderayi - steven.oderayi@modusbox.com * + **************************************************************************/ + +'use strict'; + +// we use a mock standard components lib to intercept and mock certain funcs +jest.mock('@mojaloop/sdk-standard-components'); +jest.mock('redis'); + +const util = require('util'); +const Cache = require('@internal/cache'); +const Model = require('@internal/model').OutboundBulkTransfersModel; +const { Logger, Transports } = require('@internal/log'); + +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const StateMachine = require('javascript-state-machine'); + +const defaultConfig = require('./data/defaultConfig'); +const bulkTransferRequest = require('./data/bulkTransferRequest'); +const bulkTransferFulfil = require('./data/bulkTransferFulfil'); + +// util function to simulate a bulk transfer fulfilment subscription message on a cache client +const emitBulkTransferFulfilCacheMessage = (cache, bulkTransferId, fulfils) => cache.publish(`bulkTransfer_${bulkTransferId}`, JSON.stringify(fulfils)); + +describe('outboundBulkTransferModel', () => { + let config; + let logger; + let cache; + + /** + * + * @param {Object} opts + * @param {Number} opts.expirySeconds + * @param {Object} opts.delays + * @param {Number} delays.prepareTransfer + * @param {Object} opts.rejects + * @param {boolean} rejects.transferFulfils + */ + async function testBulkTransferWithDelay({expirySeconds, delays, rejects}) { + const config = JSON.parse(JSON.stringify(defaultConfig)); + config.expirySeconds = expirySeconds; + config.rejectExpiredTransferFulfils = rejects.transferFulfils; + + // simulate a delayed callback with the bulk transfer fulfilments + MojaloopRequests.__postBulkTransfers = jest.fn((postBulkTransfersBody) => { + setTimeout(() => { + emitBulkTransferFulfilCacheMessage(cache, postBulkTransfersBody.bulkTransferId, bulkTransferFulfil); + }, delays.prepareTransfer ? delays.prepareTransfer * 1000 : 0); + }); + + const model = new Model({ + ...config, + cache, + logger, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); + + let expectError; + + if (rejects.transferFulfils && delays.prepareTransfer && expirySeconds < delays.prepareTransfer) { + expectError = 'Bulk transfer fulfils missed expiry deadline'; + } + if (expectError) { + await expect(model.run()).rejects.toThrowError(expectError); + } else { + const result = await model.run(); + await expect(result.currentState).toBe('COMPLETED'); + } + } + + beforeAll(async () => { + const logTransports = await Promise.all([Transports.consoleDir()]); + logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + }); + + beforeEach(async () => { + config = JSON.parse(JSON.stringify(defaultConfig)); + MojaloopRequests.__postBulkTransfers = jest.fn(() => Promise.resolve()); + + cache = new Cache({ + host: 'dummycachehost', + port: 1234, + logger, + }); + await cache.connect(); + }); + + afterEach(async () => { + await cache.disconnect(); + }); + + test('initializes to starting state', async () => { + const model = new Model({ + cache, + logger, + ...config, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); + expect(StateMachine.__instance.state).toBe('start'); + }); + + + test('executes bulk transfer', async () => { + MojaloopRequests.__postBulkTransfers = jest.fn((postBulkTransfersBody) => { + //ensure that the `MojaloopRequests.postBulkTransfers` method has been called with the correct arguments + // set as the destination FSPID + const extensionList = postBulkTransfersBody.extensionList.extension; + expect(extensionList).toBeTruthy(); + expect(extensionList.length).toBe(2); + expect(extensionList[0]).toEqual({ key: 'tkey1', value: 'tvalue1' }); + expect(extensionList[1]).toEqual({ key: 'tkey2', value: 'tvalue2' }); + + // simulate a callback with the transfer fulfilment + emitBulkTransferFulfilCacheMessage(cache, postBulkTransfersBody.bulkTransferId, bulkTransferFulfil); + return Promise.resolve(); + }); + + const model = new Model({ + cache, + logger, + ...config, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); + + expect(StateMachine.__instance.state).toBe('start'); + + // start the model running + const result = await model.run(); + + console.log(`Result after bulk transfer stage: ${util.inspect(result)}`); + + expect(MojaloopRequests.__postBulkTransfers).toHaveBeenCalledTimes(1); + + // check we stopped at succeeded state + expect(result.currentState).toBe('COMPLETED'); + expect(StateMachine.__instance.state).toBe('succeeded'); + }); + + test('test get bulk transfer', async () => { + MojaloopRequests.__getBulkTransfers = jest.fn((bulkTransferId) => { + emitBulkTransferFulfilCacheMessage(cache, bulkTransferId, bulkTransferFulfil); + return Promise.resolve(); + }); + + const model = new Model({ + cache, + logger, + ...config, + }); + + const BULK_TRANSFER_ID = 'btx-id000011'; + + await model.initialize(JSON.parse(JSON.stringify({ + ...bulkTransferRequest, + currentState: 'getBulkTransfer', + bulkTransferId: BULK_TRANSFER_ID, + }))); + + expect(StateMachine.__instance.state).toBe('getBulkTransfer'); + + // start the model running + const result = await model.run(); + + console.log(`Result after get bulk transfer: ${util.inspect(result)}`); + + expect(MojaloopRequests.__getBulkTransfers).toHaveBeenCalledTimes(1); + + // check we stopped at succeeded state + expect(result.currentState).toBe('COMPLETED'); + expect(StateMachine.__instance.state).toBe('succeeded'); + }); + + test('pass transfer fulfills `expiration` deadline', () => + testBulkTransferWithDelay({ + expirySeconds: 2, + delays: { + prepareTransfer: 1, + }, + rejects: { + transferFulfils: true, + } + }) + ); + + test('fail on transfer fulfills `expiration` deadline', () => + testBulkTransferWithDelay({ + expirySeconds: 1, + delays: { + prepareTransfer: 2, + }, + rejects: { + transferFulfils: true, + } + }) + ); + + + test('Throws with mojaloop error in response body when transfer request error callback occurs', async () => { + const expectError = { + type: 'bulkTransferError', + data: { + errorInformation: { + errorCode: '4001', + errorDescription: 'Payer FSP insufficient liquidity' + } + } + }; + + MojaloopRequests.__postBulkTransfers = jest.fn((postBulkTransfersBody) => { + // simulate an error callback with the transfer fulfilments + cache.publish(`bulkTransfer_${postBulkTransfersBody.bulkTransferId}`, JSON.stringify(expectError)); + return Promise.resolve(); + }); + + const model = new Model({ + cache, + logger, + ...config, + }); + + await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); + + expect(StateMachine.__instance.state).toBe('start'); + + const errMsg = 'Got an error response preparing bulk transfer: { errorInformation:\n { errorCode: \'4001\',\n errorDescription: \'Payer FSP insufficient liquidity\' } }'; + + try { + await model.run(); + } + catch(err) { + expect(err.message.replace(/[ \n]/g,'')).toEqual(errMsg.replace(/[ \n]/g,'')); + expect(err.bulkTransferState).toBeTruthy(); + expect(err.bulkTransferState.lastError).toBeTruthy(); + expect(err.bulkTransferState.lastError.mojaloopError).toEqual(expectError.data); + expect(err.bulkTransferState.lastError.bulkTransferState).toBe(undefined); + return; + } + + throw new Error('Outbound model should have thrown'); + }); +}); diff --git a/src/test/unit/lib/model/data/bulkQuoteRequest.json b/src/test/unit/lib/model/data/bulkQuoteRequest.json new file mode 100644 index 000000000..8d34c0eec --- /dev/null +++ b/src/test/unit/lib/model/data/bulkQuoteRequest.json @@ -0,0 +1,27 @@ +{ + "from": { + "displayName": "Steven Oderayi", + "idType": "MSISDN", + "idValue": "234567890" + }, + "individualQuotes": [ + { + "to": { + "fspId": "fake-fsp-id", + "idType": "PERSONAL_ID", + "idValue": "123456789", + "idSubValue": "PASSPORT" + }, + "amountType": "SEND", + "currency": "USD", + "amount": "100", + "transactionType": "TRANSFER", + "note": "test payment" + } + ], + "homeTransactionId": "123ABC", + "extensions": [ + { "key": "qkey1", "value": "qvalue1" }, + { "key": "qkey2", "value": "qvalue2" } + ] +} diff --git a/src/test/unit/lib/model/data/bulkQuoteResponse.json b/src/test/unit/lib/model/data/bulkQuoteResponse.json new file mode 100644 index 000000000..72cc09abf --- /dev/null +++ b/src/test/unit/lib/model/data/bulkQuoteResponse.json @@ -0,0 +1,35 @@ +{ + "type": "bulkQuoteResponse", + "data": { + "individualQuoteResults": [ + { + "transferAmount": { + "amount": "500", + "currency": "USD" + }, + "payeeReceiveAmount": { + "amount": "490", + "currency": "USD" + }, + "payeeFspFee": { + "amount": "5", + "currency": "USD" + }, + "payeeFspCommission": { + "amount": "5", + "currency": "USD" + } + } + ], + "geoCode": { + "latitude": "53.295971", + "longitude": "-0.038500" + }, + "expiration": "2017-11-15T14:17:09.663+01:00", + "ilpPacket": "AQAAAAAAACasIWcuc2UubW9iaWxlbW9uZXkubXNpc2RuLjEyMzQ1Njc4OYIEIXsNCiAgICAidHJhbnNhY3Rpb25JZCI6ICI4NWZlYWMyZi0zOWIyLTQ5MWItODE3ZS00YTAzMjAzZDRmMTQiLA0KICAgICJxdW90ZUlkIjogIjdjMjNlODBjLWQwNzgtNDA3Ny04MjYzLTJjMDQ3ODc2ZmNmNiIsDQogICAgInBheWVlIjogew0KICAgICAgICAicGFydHlJZEluZm8iOiB7DQogICAgICAgICAgICAicGFydHlJZFR5cGUiOiAiTVNJU0ROIiwNCiAgICAgICAgICAgICJwYXJ0eUlkZW50aWZpZXIiOiAiMTIzNDU2Nzg5IiwNCiAgICAgICAgICAgICJmc3BJZCI6ICJNb2JpbGVNb25leSINCiAgICAgICAgfSwNCiAgICAgICAgInBlcnNvbmFsSW5mbyI6IHsNCiAgICAgICAgICAgICJjb21wbGV4TmFtZSI6IHsNCiAgICAgICAgICAgICAgICAiZmlyc3ROYW1lIjogIkhlbnJpayIsDQogICAgICAgICAgICAgICAgImxhc3ROYW1lIjogIkthcmxzc29uIg0KICAgICAgICAgICAgfQ0KICAgICAgICB9DQogICAgfSwNCiAgICAicGF5ZXIiOiB7DQogICAgICAgICJwZXJzb25hbEluZm8iOiB7DQogICAgICAgICAgICAiY29tcGxleE5hbWUiOiB7DQogICAgICAgICAgICAgICAgImZpcnN0TmFtZSI6ICJNYXRzIiwNCiAgICAgICAgICAgICAgICAibGFzdE5hbWUiOiAiSGFnbWFuIg0KICAgICAgICAgICAgfQ0KICAgICAgICB9LA0KICAgICAgICAicGFydHlJZEluZm8iOiB7DQogICAgICAgICAgICAicGFydHlJZFR5cGUiOiAiSUJBTiIsDQogICAgICAgICAgICAicGFydHlJZGVudGlmaWVyIjogIlNFNDU1MDAwMDAwMDA1ODM5ODI1NzQ2NiIsDQogICAgICAgICAgICAiZnNwSWQiOiAiQmFua05yT25lIg0KICAgICAgICB9DQogICAgfSwNCiAgICAiYW1vdW50Ijogew0KICAgICAgICAiYW1vdW50IjogIjEwMCIsDQogICAgICAgICJjdXJyZW5jeSI6ICJVU0QiDQogICAgfSwNCiAgICAidHJhbnNhY3Rpb25UeXBlIjogew0KICAgICAgICAic2NlbmFyaW8iOiAiVFJBTlNGRVIiLA0KICAgICAgICAiaW5pdGlhdG9yIjogIlBBWUVSIiwNCiAgICAgICAgImluaXRpYXRvclR5cGUiOiAiQ09OU1VNRVIiDQogICAgfSwNCiAgICAibm90ZSI6ICJGcm9tIE1hdHMiDQp9DQo\u003d\u003d", + "condition": "fH9pAYDQbmoZLPbvv3CSW2RfjU4jvM4ApG_fqGnR7Xs" + }, + "headers": { + "fspiop-source": "foo" + } +} diff --git a/src/test/unit/lib/model/data/bulkTransferFulfil.json b/src/test/unit/lib/model/data/bulkTransferFulfil.json new file mode 100644 index 000000000..52f0a177f --- /dev/null +++ b/src/test/unit/lib/model/data/bulkTransferFulfil.json @@ -0,0 +1,13 @@ +{ + "type": "bulkTransferFulfil", + "data": { + "individualTransferResults": [ + { + "transferId": "fake-transfer-id", + "fulfilment": "87mm1-reS3SAi8oIWXgBkLmgWc1MkZ_yLbFDX5XAdo5o" + } + ], + "completedTimestamp": "2017-11-15T14:16:09.663+01:00", + "bulkTransferState": "COMMITTED" + } +} diff --git a/src/test/unit/lib/model/data/bulkTransferRequest.json b/src/test/unit/lib/model/data/bulkTransferRequest.json new file mode 100644 index 000000000..fd4f3c8e6 --- /dev/null +++ b/src/test/unit/lib/model/data/bulkTransferRequest.json @@ -0,0 +1,29 @@ +{ + "from": { + "displayName": "James Bush", + "idType": "MSISDN", + "idValue": "447710066017" + }, + "payeeFsp": { + "fspId": "fake-payeefsp-id" + }, + "homeTransactionId": "123ABC", + "individualTransfers": [ + { + "to": { + "idType": "PERSONAL_ID", + "idValue": "123456789", + "idSubValue": "PASSPORT" + }, + "amountType": "SEND", + "currency": "USD", + "amount": "100", + "transactionType": "TRANSFER", + "note": "test payment" + } + ], + "extensions": [ + { "key": "tkey1", "value": "tvalue1" }, + { "key": "tkey2", "value": "tvalue2" } + ] +} diff --git a/src/test/unit/lib/model/data/getBulkTransfersBackendResponse.json b/src/test/unit/lib/model/data/getBulkTransfersBackendResponse.json new file mode 100644 index 000000000..d644eabe0 --- /dev/null +++ b/src/test/unit/lib/model/data/getBulkTransfersBackendResponse.json @@ -0,0 +1,42 @@ +{ + "homeTransactionId": "home-tx-001", + "from": { + "displayName": "John Doe", + "idType": "MSISDN", + "idValue": "123456789" + }, + "internalRequest": { + "individualTransfers": [ + { + "transferId": "fake-transfer-id", + "to": { + "idType": "PERSONAL_ID", + "idValue": "987654321", + "idSubValue": "PASSPORT", + "fspId": "mojaloop-sdk" + }, + "amount": "100", + "currency": "USD", + "amountType": "SEND", + "transactionType": { + "initiator": "PAYER", + "initiatorType": "CONSUMER", + "scenario": "TRANSFER" + } + } + ] + }, + "bulkTransferState": "COMMITTED", + "timestamp": "2019-11-15T14:16:09.663", + "note": "test payment", + "extensions": [ + { + "key": "treskey1", + "value": "tresvalue1" + }, + { + "key": "treskey2", + "value": "tresvalue2" + } + ] +} \ No newline at end of file diff --git a/src/test/unit/lib/model/data/getBulkTransfersMojaloopResponse.json b/src/test/unit/lib/model/data/getBulkTransfersMojaloopResponse.json new file mode 100644 index 000000000..e7e58fa98 --- /dev/null +++ b/src/test/unit/lib/model/data/getBulkTransfersMojaloopResponse.json @@ -0,0 +1,22 @@ +{ + "completedTimestamp": "2019-11-15T14:16:09.663", + "extensionList": { + "extension": [ + { + "key": "treskey1", + "value": "tresvalue1" + }, + { + "key": "treskey2", + "value": "tresvalue2" + } + ] + }, + "bulkTransferState": "COMMITTED", + "individualTransferResults": [ + { + "transferId": "fake-transfer-id", + "fulfilment": "mockGeneratedFulfilment" + } + ] +} diff --git a/src/test/unit/lib/model/data/mockArguments.json b/src/test/unit/lib/model/data/mockArguments.json index b6650ac9a..e1335f0da 100644 --- a/src/test/unit/lib/model/data/mockArguments.json +++ b/src/test/unit/lib/model/data/mockArguments.json @@ -60,5 +60,72 @@ "internalGetOTPResponse" : { "value": "123456" }, - "fspId": "fake-fsp-id" + "fspId": "fake-fsp-id", + "bulkQuoteRequest": { + "bulkQuoteId": "fake-bulk-quote-id", + "payer": { + "partyIdInfo": { + "partyIdType": "MSISDN", + "partyIdentifier": "17855501914", + "fspId": "mojaloop-sdk" + }, + "personalInfo": { + "complexName": { + "firstName": "John", + "lastName": "Doe" + }, + "dateOfBirth": "2010-10-10" + }, + "name": "John Doe", + "merchantClassificationCode": "123" + }, + "expiration": "2019-06-04T04:02:10.378Z", + "individualQuotes": [ + { + "quoteId": "fake-quote-id", + "transactionId": "fake-transaction-id", + "payee": { + "partyIdInfo": { + "partyIdType": "PERSONAL_ID", + "partyIdentifier": "123456789", + "partySubIdOrType": "PASSPORT", + "fspId": "goldenpayeefsp" + }, + "personalInfo": { + "complexName": { + "firstName": "Jane", + "lastName": "Doe" + }, + "dateOfBirth": "2010-10-10" + }, + "name": "Jane Doe", + "merchantClassificationCode": "456" + }, + "amountType": "SEND", + "amount": { + "currency": "XOF", + "amount": 10 + }, + "transactionType": { + "scenario": "TRANSFER", + "initiator": "PAYER", + "initiatorType": "CONSUMER" + } + } + ] + }, + "internalBulkQuoteResponse": { + "expiration": "2019-11-12T09:02:10.378Z", + "individualQuotes": [ + { + "quoteId": "fake-quote-id", + "transferAmount": 500, + "transferAmountCurrency": "XOF", + "payeeReceiveAmount": 490, + "payeeFspFee": 10, + "payeeFspCommission": 0, + "condition": "fH9pAYDQbmoZLPbvv3CSW2RfjU4jvM4ApG_fqGnR7Xs" + } + ] + } } diff --git a/src/test/unit/outboundApi/data/bulkQuoteRequest.json b/src/test/unit/outboundApi/data/bulkQuoteRequest.json new file mode 100644 index 000000000..87037f899 --- /dev/null +++ b/src/test/unit/outboundApi/data/bulkQuoteRequest.json @@ -0,0 +1,28 @@ +{ + "homeTransactionId": "123ABC", + "from": { + "displayName": "James Bush", + "idType": "MSISDN", + "idValue": "447710066017", + "extensionList": [ + { + "key": "accountType", + "value": "Wallet" + } + ] + }, + "individualQuotes": [ + { + "quoteId": "12346789", + "to": { + "idType": "MSISDN", + "idValue": "1234567890" + }, + "amountType": "SEND", + "currency": "USD", + "amount": "100", + "transactionType": "TRANSFER", + "note": "test payment" + } + ] +} \ No newline at end of file diff --git a/src/test/unit/outboundApi/data/bulkTransferRequest.json b/src/test/unit/outboundApi/data/bulkTransferRequest.json new file mode 100644 index 000000000..881ee3857 --- /dev/null +++ b/src/test/unit/outboundApi/data/bulkTransferRequest.json @@ -0,0 +1,28 @@ +{ + "homeTransactionId": "123ABC", + "from": { + "displayName": "James Bush", + "idType": "MSISDN", + "idValue": "447710066017", + "extensionList": [ + { + "key": "accountType", + "value": "Wallet" + } + ] + }, + "individualTransfers": [ + { + "transferId": "132435", + "to": { + "idType": "MSISDN", + "idValue": "1234567890" + }, + "amountType": "SEND", + "currency": "USD", + "amount": "100", + "transactionType": "TRANSFER", + "note": "test payment" + } + ] +} \ No newline at end of file diff --git a/src/test/unit/outboundApi/data/mockBulkQuoteError.json b/src/test/unit/outboundApi/data/mockBulkQuoteError.json new file mode 100644 index 000000000..ca6eb7906 --- /dev/null +++ b/src/test/unit/outboundApi/data/mockBulkQuoteError.json @@ -0,0 +1,45 @@ +{ + "message": "Mock error", + "httpStatusCode": 500, + "bulkQuoteState": { + "from": { + "displayName": "James Bush", + "idType": "MSISDN", + "idValue": "447710066017" + }, + "individualQuoteResults": [ + { + "to": { + "idType": "MSISDN", + "idValue": "1234567890" + }, + "amountType": "SEND", + "currency": "USD", + "amount": "100", + "transactionType": "TRANSFER", + "note": "test payment", + "homeTransactionId": "123ABC", + "transferId": "5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5", + "currentState": "ERROR_OCCURRED" + } + ], + "lastError": { + "httpStatusCode": 500, + "mojaloopError": { + "errorInformation": { + "errorCode": "3204", + "errorDescription": "Party not found", + "extensionList": { + "extension": [{ + "key": "someKey", + "value": "someValue" + }, { + "key": "extErrorKey", + "value": "9999" + }] + } + } + } + } + } +} diff --git a/src/test/unit/outboundApi/data/mockBulkTransferError.json b/src/test/unit/outboundApi/data/mockBulkTransferError.json new file mode 100644 index 000000000..00f6772b9 --- /dev/null +++ b/src/test/unit/outboundApi/data/mockBulkTransferError.json @@ -0,0 +1,48 @@ +{ + "message": "Mock error", + "statusCode": 500, + "bulkTransferState": { + "from": { + "displayName": "James Bush", + "idType": "MSISDN", + "idValue": "447710066017" + }, + "to": { + "idType": "MSISDN", + "idValue": "1234567890" + }, + "individualTransferResults": [ + { + "transferId": "5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5", + "amountType": "SEND", + "currency": "USD", + "amount": "100", + "transactionType": "TRANSFER", + "note": "test payment", + "homeTransactionId": "123ABC", + "currentState": "ERROR_OCCURRED" + } + ], + "lastError": { + "httpStatusCode": 500, + "mojaloopError": { + "errorInformation": { + "errorCode": "3204", + "errorDescription": "Party not found", + "extensionList": { + "extension": [ + { + "key": "someKey", + "value": "someValue" + }, + { + "key": "extErrorKey", + "value": "9999" + } + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/src/test/unit/outboundApi/handlers.test.js b/src/test/unit/outboundApi/handlers.test.js index 8c4852bfb..94038773d 100644 --- a/src/test/unit/outboundApi/handlers.test.js +++ b/src/test/unit/outboundApi/handlers.test.js @@ -11,9 +11,13 @@ 'use strict'; const mockError = require('./data/mockError'); +const mockBulkQuoteError = require('./data/mockBulkQuoteError'); +const mockBulkTransferError = require('./data/mockBulkTransferError'); const mockRequestToPayError = require('./data/mockRequestToPayError'); const mockRequestToPayTransferError = require('./data/mockRequestToPayTransferError'); const transferRequest = require('./data/transferRequest'); +const bulkTransferRequest = require('./data/bulkTransferRequest'); +const bulkQuoteRequest = require('./data/bulkQuoteRequest'); const requestToPayPayload = require('./data/requestToPay'); const requestToPayTransferRequest = require('./data/requestToPayTransferRequest'); const mockLogger = require('../mockLogger'); @@ -21,7 +25,14 @@ const mockLogger = require('../mockLogger'); jest.mock('@internal/model'); const handlers = require('../../../OutboundServer/handlers'); -const { OutboundTransfersModel, OutboundRequestToPayTransferModel, OutboundRequestToPayModel, OutboundAuthorizationsModel } = require('@internal/model'); +const { + OutboundTransfersModel, + OutboundBulkTransfersModel, + OutboundBulkQuotesModel, + OutboundRequestToPayTransferModel, + OutboundRequestToPayModel, + OutboundAuthorizationsModel, +} = require('@internal/model'); /** * Mock the outbound transfer model to simulate throwing errors @@ -43,6 +54,40 @@ OutboundTransfersModel.mockImplementation(() => { }; }); +/** + * Mock the outbound bulk transfers model to simulate throwing errors + */ +OutboundBulkTransfersModel.mockImplementation(() => { + return { + run: async () => { + throw mockBulkTransferError; + }, + initialize: async () => { + return; + }, + load: async () => { + return; + } + }; +}); + +/** + * Mock the outbound bulk quotes model to simulate throwing errors + */ +OutboundBulkQuotesModel.mockImplementation(() => { + return { + run: async () => { + throw mockBulkQuoteError; + }, + initialize: async () => { + return; + }, + load: async () => { + return; + } + }; +}); + /** * Mock the outbound transfer model to simulate throwing errors */ @@ -143,11 +188,45 @@ describe('Outbound API handlers:', () => { }); }); - describe('POST /requestToPayTransfer', () => { + describe('PUT /transfers', () => { test('returns correct error response body when model throws mojaloop error', async () => { const mockContext = { request: { - body: requestToPayTransferRequest, + body: { + acceptQuote: true + }, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + logger: console, + path: { + params: { + transferId: '12345' + } + } + } + }; + + await handlers['/transfers/{transferId}'].put(mockContext); + + // check response is correct + expect(mockContext.response.status).toEqual(500); + expect(mockContext.response.body).toBeTruthy(); + expect(mockContext.response.body.message).toEqual('Mock error'); + expect(mockContext.response.body.statusCode).toEqual('3204'); + expect(mockContext.response.body.transferState).toEqual(mockError.transferState); + }); + }); + + describe('POST /bulkTransfers', () => { + test('returns correct error response body when model throws mojaloop error', async () => { + const mockContext = { + request: { + body: bulkTransferRequest, headers: { 'fspiop-source': 'foo' } @@ -159,25 +238,53 @@ describe('Outbound API handlers:', () => { } }; - await handlers['/requestToPayTransfer'].post(mockContext); + await handlers['/bulkTransfers'].post(mockContext); + // check response is correct expect(mockContext.response.status).toEqual(500); expect(mockContext.response.body).toBeTruthy(); expect(mockContext.response.body.message).toEqual('Mock error'); expect(mockContext.response.body.statusCode) - .toEqual(mockRequestToPayTransferError.requestToPayTransferState.lastError.mojaloopError.errorInformation.errorCode); - expect(mockContext.response.body.requestToPayTransferState).toEqual(mockRequestToPayTransferError.requestToPayTransferState); + .toEqual(mockBulkTransferError.bulkTransferState.lastError.mojaloopError.errorInformation.errorCode); + expect(mockContext.response.body.bulkTransferState).toEqual(mockBulkTransferError.bulkTransferState); }); - }); + test('uses correct extension list error code for response body statusCode when configured to do so', async () => { + const mockContext = { + request: { + body: bulkTransferRequest, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: { + outboundErrorStatusCodeExtensionKey: 'extErrorKey' // <- tell the handler to use this extensionList item as source of statusCode + }, + logger: console + } + }; + + await handlers['/bulkTransfers'].post(mockContext); - describe('PUT /transfers', () => { + // check response is correct + expect(mockContext.response.status).toEqual(500); + expect(mockContext.response.body).toBeTruthy(); + expect(mockContext.response.body.message).toEqual('Mock error'); + + // in this case, where we have set outboundErrorExtensionKey config we expect the error body statusCode + // property to come from the extensionList item with the corresponding key 'extErrorKey' + expect(mockContext.response.body.statusCode).toEqual('9999'); + expect(mockContext.response.body.bulkTransferState).toEqual(mockBulkTransferError.bulkTransferState); + }); + }); + + describe('POST /bulkQuotes', () => { test('returns correct error response body when model throws mojaloop error', async () => { const mockContext = { request: { - body: { - acceptQuote: true - }, + body: bulkQuoteRequest, headers: { 'fspiop-source': 'foo' } @@ -185,23 +292,76 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: mockLogger({ app: 'outbound-api-handlers-test'}), - path: { - params: { - transferId: '12345' - } + logger: console + } + }; + + await handlers['/bulkQuotes'].post(mockContext); + + // check response is correct + expect(mockContext.response.status).toEqual(500); + expect(mockContext.response.body).toBeTruthy(); + expect(mockContext.response.body.message).toEqual('Mock error'); + expect(mockContext.response.body.statusCode) + .toEqual(mockBulkQuoteError.bulkQuoteState.lastError.mojaloopError.errorInformation.errorCode); + expect(mockContext.response.body.bulkQuoteState).toEqual(mockBulkQuoteError.bulkQuoteState); + }); + + test('uses correct extension list error code for response body statusCode when configured to do so', async () => { + const mockContext = { + request: { + body: bulkQuoteRequest, + headers: { + 'fspiop-source': 'foo' } + }, + response: {}, + state: { + conf: { + outboundErrorStatusCodeExtensionKey: 'extErrorKey' // <- tell the handler to use this extensionList item as source of statusCode + }, + logger: console } }; - await handlers['/transfers/{transferId}'].put(mockContext); + await handlers['/bulkQuotes'].post(mockContext); // check response is correct expect(mockContext.response.status).toEqual(500); expect(mockContext.response.body).toBeTruthy(); expect(mockContext.response.body.message).toEqual('Mock error'); - expect(mockContext.response.body.statusCode).toEqual('3204'); - expect(mockContext.response.body.transferState).toEqual(mockError.transferState); + + // in this case, where we have set outboundErrorExtensionKey config we expect the error body statusCode + // property to come from the extensionList item with the corresponding key 'extErrorKey' + expect(mockContext.response.body.statusCode).toEqual('9999'); + expect(mockContext.response.body.bulkQuoteState).toEqual(mockBulkQuoteError.bulkQuoteState); + }); + }); + + describe('POST /requestToPayTransfer', () => { + test('returns correct error response body when model throws mojaloop error', async () => { + const mockContext = { + request: { + body: requestToPayTransferRequest, + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + logger: console + } + }; + + await handlers['/requestToPayTransfer'].post(mockContext); + // check response is correct + expect(mockContext.response.status).toEqual(500); + expect(mockContext.response.body).toBeTruthy(); + expect(mockContext.response.body.message).toEqual('Mock error'); + expect(mockContext.response.body.statusCode) + .toEqual(mockRequestToPayTransferError.requestToPayTransferState.lastError.mojaloopError.errorInformation.errorCode); + expect(mockContext.response.body.requestToPayTransferState).toEqual(mockRequestToPayTransferError.requestToPayTransferState); }); }); From 4bad03c9774a356fe34d6c0f5c4634349c740c65 Mon Sep 17 00:00:00 2001 From: eoln <2881004+eoln@users.noreply.github.com> Date: Wed, 15 Jul 2020 15:48:30 +0200 Subject: [PATCH 11/29] chore(package): latest sdk-standard-components (#180) * chore(package): latest sdk-standard-components * chore(package): update deps * unmock sdk-standard-components postAuthorization * update * test: fix broken tests * test: finish integrations tests --- src/OutboundServer/api.yaml | 15 +- src/OutboundServer/handlers.js | 1 - src/lib/model/OutboundAuthorizationsModel.js | 106 ++++++------- .../model/common/PersistentStateMachine.js | 7 +- src/package.json | 4 +- .../@mojaloop/sdk-standard-components.js | 12 +- .../model/OutboundAuthorizationsModel.test.js | 139 ++++++------------ .../common/PersistentStateMachine.test.js | 27 +--- src/test/unit/mockLogger.js | 4 +- src/test/unit/outboundApi/handlers.test.js | 21 +-- 10 files changed, 136 insertions(+), 200 deletions(-) diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index ae71f1577..461fc7fc9 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -1599,6 +1599,8 @@ components: description: POST /authorizations Request object type: object properties: + toParticipantId: + type: string authenticationType: $ref: '#/components/schemas/mojaloopAuthenticationType' retriesLeft: @@ -1639,7 +1641,18 @@ components: resend of the authentication value. TODO: enum specification will be defined when all values will be known type: string - + currentState: + $ref: '#/components/schemas/authorizationsState' + + authorizationsState: + title: authorizationsState + description: state of POST authorizations + type: string + enum: + - WAITING_FOR_AUTHORIZATION_REQUEST + - COMPLETED + - ERROR_OCCURRED + bulkTransferRequest: type: object required: diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index 1cc1d70eb..e7f757898 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -447,7 +447,6 @@ const postAuthorizations = async (ctx) => { }; const cacheKey = `post_authorizations_${authorizationsRequest.transactionRequestId}`; - // use the authorizations model to execute asynchronous stages with the switch const model = await OutboundAuthorizationsModel.create(authorizationsRequest, cacheKey, modelConfig); diff --git a/src/lib/model/OutboundAuthorizationsModel.js b/src/lib/model/OutboundAuthorizationsModel.js index e6d0c80a3..5ecdd9773 100644 --- a/src/lib/model/OutboundAuthorizationsModel.js +++ b/src/lib/model/OutboundAuthorizationsModel.js @@ -12,16 +12,15 @@ const util = require('util'); const { uuid } = require('uuidv4'); -const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); const PSM = require('./common').PersistentStateMachine; +const ThirdpartyRequests = require('@mojaloop/sdk-standard-components').ThirdpartyRequests; const specStateMachine = { init: 'start', transitions: [ { name: 'init', from: 'none', to: 'start' }, - { name: 'requestAuthorization', from: 'start', to: 'waitingForAuthorization' }, - { name: 'authorizationReceived', from: 'waitingForAuthorization', to: 'succeeded'}, + { name: 'requestAuthorization', from: 'start', to: 'succeeded' }, { name: 'error', from: '*', to: 'errored' }, ], methods: { @@ -31,14 +30,13 @@ const specStateMachine = { // specific transitions handlers methods onRequestAuthorization, - onAuthorizationReceived } }; /** * runs the workflow */ -async function run(message) { +async function run() { const { data, logger } = this.context; try { // run transitions based on incoming state @@ -46,14 +44,9 @@ async function run(message) { case 'start': // the first transition is requestAuthorization await this.requestAuthorization(); - logger.log(`Authorization requested for ${data.transactionRequestId}`); - return this.getResponse(); - - case 'waitingForAuthorization': - await this.authorizationReceived(message); - logger.log(`Authorization received for ${data.transactionRequestId}`); - return this.getResponse(); + logger.log(`Authorization requested for ${data.transactionRequestId}, currentState: ${data.currentState}`); + // eslint-disable-next-line no-fallthrough case 'succeeded': // all steps complete so return logger.log('Authorization completed successfully'); @@ -65,11 +58,6 @@ async function run(message) { return; } - // now call ourselves recursively to deal with the next transition - // in this scenario defined in switch statement ^ this part of code is not reachable because of return in every case !!! - // logger.log(`Authorization model state machine transition completed in state: ${this.state}. Recursing to handle next transition.`); - // return run(); - } catch (err) { logger.log(`Error running authorizations model: ${util.inspect(err)}`); @@ -92,7 +80,6 @@ async function run(message) { const mapCurrentState = { start: 'WAITING_FOR_AUTHORIZATION_REQUEST', - waitingForAuthorization: 'WAITING_FOR_AUTHORIZATION_RESPONSE', succeeded: 'COMPLETED', errored: 'ERROR_OCCURRED' }; @@ -141,55 +128,56 @@ async function onRequestAuthorization() { const channel = notificationChannel(data.transactionRequestId); let subId; - try { - // in InboundServer/handlers is implemented putAuthorizationsById handler where this event is fired - subId = await cache.subscribe(channel, async (channel, message, sid) => { - try { - await this.run(message); - // there is no need to block execution using await here - } finally { - cache.unsubscribe(sid); + // eslint-disable-next-line no-async-promise-executor + return new Promise( async(resolve, reject) => { + + try { + // in InboundServer/handlers is implemented putAuthorizationsById handler + // where this event is fired but only if env ENABLE_PISP_MODE=true + subId = await cache.subscribe(channel, async (channel, message, sid) => { + + try { + const parsed = JSON.parse(message); + this.context.data = { + ...parsed.data, + currentState: this.state + }; + resolve(); + } catch(err) { + reject(err); + } finally { + if(sid) { + cache.unsubscribe(sid); + } + } + }); + + // POST /authorization request to the switch + const postRequest = buildPostAuthorizationsRequest(data, config); + const res = await requests.postAuthorizations(postRequest, data.toParticipantId); + + logger.push({ res }).log('Authorizations request sent to peer'); + + } catch(error) { + logger.push(error).error('Authorization request error'); + if(subId) { + cache.unsubscribe(subId); } - - }); - - // POST /authorization request to the switch - const postRequest = buildPostAuthorizationsRequest(data, config); - - // TODO: postAuthorizations is mocked method until this feature arrive in MojaloopRequests - const res = await requests.postAuthorizations(postRequest); - - logger.push({ res }).log('Authorizations request sent to peer'); - - } catch(error) { - cache.unsubscribe(subId); - throw error; - } -} - - -/** - * Propagates the Authorization - * we got the notification on PUT /authorizations/ @ InboundServer - * so we can propagate it back to DFSP - * - * - */ -async function onAuthorizationReceived(message) { - // mvp validation - if(!(message && typeof message === 'object' && message.body && typeof message.body === 'object' )) { - throw new Error('OutboundAuthorizationsModel.onAuthorizationReceived: invalid \'message\' parameter is required'); - } - this.context.data = message.body; + reject(error); + } + }); } - function buildPostAuthorizationsRequest(data/** , config */) { // TODO: the request object must be valid to schema defined in sdk-standard-components const request = { ...data }; + // drop properties not conforming to the txr service schema + delete request.toParticipantId; + delete request.currentState; + return request; } @@ -208,7 +196,7 @@ function injectHandlersContext(config, specStateMachine) { data: { handlersContext: { config, // injects config property - requests: new MojaloopRequests({ + requests: new ThirdpartyRequests({ logger: config.logger, peerEndpoint: config.peerEndpoint, alsEndpoint: config.alsEndpoint, diff --git a/src/lib/model/common/PersistentStateMachine.js b/src/lib/model/common/PersistentStateMachine.js index f225e344f..bff1253aa 100644 --- a/src/lib/model/common/PersistentStateMachine.js +++ b/src/lib/model/common/PersistentStateMachine.js @@ -24,16 +24,15 @@ async function saveToCache() { } async function onAfterTransition(transition) { - const {data, logger} = this.context; + const { logger } = this.context; logger.log(`State machine transitioned '${transition.transition}': ${transition.from} -> ${transition.to}`); - data.currentState = transition.to; - await this.saveToCache(); + this.context.data.currentState = transition.to; } function onPendingTransition(transition) { // allow transitions to 'error' state while other transitions are in progress if(transition !== 'error') { - throw new Error(`Transition requested while another transition is in progress: ${transition}`); + throw new Error(`Transition '${transition}' requested while another transition is in progress.`); } } diff --git a/src/package.json b/src/package.json index 4c20534a6..72026d316 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@mojaloop/sdk-scheme-adapter", - "version": "10.5.1", + "version": "10.6.0", "description": "An adapter for connecting to Mojaloop API enabled switches.", "main": "index.js", "scripts": { @@ -29,7 +29,7 @@ "@internal/router": "file:lib/router", "@internal/shared": "file:lib/model/lib/shared", "@internal/validate": "file:lib/validate", - "@mojaloop/sdk-standard-components": "^10.2.3", + "@mojaloop/sdk-standard-components": "^10.6.1", "ajv": "^6.12.2", "co-body": "^6.0.0", "dotenv": "^8.2.0", diff --git a/src/test/__mocks__/@mojaloop/sdk-standard-components.js b/src/test/__mocks__/@mojaloop/sdk-standard-components.js index 4bda12dd0..f567b6ec1 100644 --- a/src/test/__mocks__/@mojaloop/sdk-standard-components.js +++ b/src/test/__mocks__/@mojaloop/sdk-standard-components.js @@ -11,7 +11,7 @@ 'use strict'; const util = require('util'); -const { MojaloopRequests, Errors, WSO2Auth, Jws } = jest.requireActual('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, ThirdpartyRequests, Errors, WSO2Auth, Jws } = jest.requireActual('@mojaloop/sdk-standard-components'); class MockMojaloopRequests extends MojaloopRequests { @@ -67,6 +67,15 @@ MockMojaloopRequests.__putBulkTransfers = jest.fn(() => Promise.resolve()); MockMojaloopRequests.__putBulkTransfersError = jest.fn(() => Promise.resolve()); +class MockThirdpartyRequests extends ThirdpartyRequests { + constructor(...args) { + super(...args); + MockThirdpartyRequests.__instance = this; + this.postAuthorizations = MockMojaloopRequests.__postAuthorizations; + } +} +MockMojaloopRequests.__postAuthorizations = jest.fn(() => Promise.resolve()); + class MockIlp { constructor(config) { console.log('MockIlp constructed'); @@ -138,6 +147,7 @@ class MockJwsSigner { module.exports = { MojaloopRequests: MockMojaloopRequests, + ThirdpartyRequests: MockThirdpartyRequests, Ilp: MockIlp, Jws: { validator: MockJwsValidator, diff --git a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js index bdd3b4af7..09f2ba985 100644 --- a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js +++ b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js @@ -67,7 +67,7 @@ describe('authorizationsModel', () => { }, ...defaultConfig }; - data = {the: 'mocked data'}; + data = {the: 'mocked data', toParticipantId: 'pisp'}; }); describe('create', () => { @@ -79,7 +79,7 @@ describe('authorizationsModel', () => { // model's methods layout const methods = [ 'run', 'getResponse', - 'onRequestAuthorization', 'onAuthorizationReceived' + 'onRequestAuthorization' ]; methods.forEach((method) => expect(typeof model[method]).toEqual('function')); @@ -146,37 +146,6 @@ describe('authorizationsModel', () => { }); - describe('onAuthorizationReceived', () => { - it('should validate input', async () => { - const invalidMessages = [ - null, - undefined, - {}, - {body: null} - ]; - const model = await Model.create(data, cacheKey, modelConfig); - - const testCases = invalidMessages.map(async (msg) => { - expect(() => model.onAuthorizationReceived(msg)) - .rejects.ToEqual(new Error('OutboundAuthorizationsModel.onAuthorizationReceived: invalid \'message\' parameter is required')); - }); - - await Promise.allSettled(testCases); - }); - - it('should properly setup context.data', async () => { - const message = { - body: { - Iam: 'the-body' - } - }; - const model = await Model.create(data, cacheKey, modelConfig); - await model.onAuthorizationReceived(message); - - expect(model.context.data).toEqual(message.body); - }); - }); - describe('onRequestAuthorization', () => { it('should implement happy flow', async () => { @@ -187,40 +156,50 @@ describe('authorizationsModel', () => { // mock workflow execution which is tested in separate case model.run = jest.fn(() => Promise.resolve()); - // invoke transition handler - await model.onRequestAuthorization(); + const message = { + data: { + Iam: 'the-body', + transactionRequestId: model.context.data.transactionRequestId + } + }; - // subscribe should be called only once - expect(cache.subscribe).toBeCalledTimes(1); + // manually invoke transition handler + model.onRequestAuthorization() + .then(() => { + // subscribe should be called only once + expect(cache.subscribe).toBeCalledTimes(1); - // subscribe should be done to proper notificationChannel - expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); + // subscribe should be done to proper notificationChannel + expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); - // check invocation of request.postAuthorizations - expect(MojaloopRequests.__postAuthorizations).toBeCalledWith(Model.buildPostAuthorizationsRequest(data, modelConfig)); + // check invocation of request.postAuthorizations + expect(MojaloopRequests.__postAuthorizations).toBeCalledWith(Model.buildPostAuthorizationsRequest(data, modelConfig), data.toParticipantId); + + + // check that this.context.data is updated + expect(model.context.data).toEqual({ + Iam: 'the-body', + transactionRequestId: model.context.data.transactionRequestId, + + // current state will be updated by onAfterTransition which isn't called + // when manual invocation of transition handler happens + currentState: 'start' + }); + }); // ensure handler wasn't called before publishing the message expect(handler).not.toBeCalled(); - // ensure that cache.unsubscribe does not happened + // ensure that cache.unsubscribe does not happened before fire the message expect(cache.unsubscribe).not.toBeCalled(); + - // fire publication to channel with given message - const message = { - body: { - Iam: 'the-body', - transactionRequestId: model.context.data.transactionRequestId - } - }; - await cache.publish(channel, message); + // fire publication with given message + await cache.publish(channel, JSON.stringify(message)); // handler should be called only once expect(handler).toBeCalledTimes(1); - // the workflow should be run only once - expect(model.run).toBeCalledTimes(1); - expect(model.run).toBeCalledWith(message); - // handler should unsubscribe from notification channel expect(cache.unsubscribe).toBeCalledTimes(1); expect(cache.unsubscribe).toBeCalledWith(subId); @@ -232,28 +211,17 @@ describe('authorizationsModel', () => { const model = await Model.create(data, cacheKey, modelConfig); const { cache } = model.context; - // simulate error - model.run = jest.fn(() => Promise.reject('workflow failed')); - let theError = null; - try { - // invoke transition handler - await model.onRequestAuthorization(); + // invoke transition handler + model.onRequestAuthorization().catch((err) => { + expect(err.message).toEqual('Unexpected token u in JSON at position 0'); + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); - // fire publication to channel with given message - const message = { - body: { - Iam: 'the-body', - transactionRequestId: data.transactionRequestId - } - }; - await cache.publish(channel, message); + // fire publication to channel with invalid message + // should throw the exception from JSON.parse + await cache.publish(channel, undefined); - } catch(error) { - theError = error; - } - expect(theError).toEqual('workflow failed'); - expect(cache.unsubscribe).toBeCalledTimes(1); - expect(cache.unsubscribe).toBeCalledWith(subId); }); it('should unsubscribe from cache in case when error happens Mojaloop requests', async () => { @@ -292,25 +260,12 @@ describe('authorizationsModel', () => { expect(result).toEqual({the: 'response'}); expect(model.requestAuthorization).toBeCalledTimes(1); expect(model.getResponse).toBeCalledTimes(1); - expect(model.context.logger.log).toBeCalledWith(`Authorization requested for ${model.context.data.transactionRequestId}`); + expect(model.context.logger.log.mock.calls).toEqual([ + ['State machine transitioned \'init\': none -> start'], + [`Authorization requested for ${model.context.data.transactionRequestId}, currentState: start`], + ['Authorization completed successfully'] + ]); }); - - it('waitingForAuthorization', async () => { - const model = await Model.create(data, cacheKey, modelConfig); - - model.authorizationReceived = jest.fn(); - model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); - - model.context.data.currentState = 'waitingForAuthorization'; - const result = await model.run({the: 'message'}); - - expect(result).toEqual({the: 'response'}); - expect(model.authorizationReceived).toBeCalledTimes(1); - expect(model.authorizationReceived).toBeCalledWith({the: 'message'}); - expect(model.getResponse).toBeCalledTimes(1); - expect(model.context.logger.log).toBeCalledWith(`Authorization received for ${model.context.data.transactionRequestId}`); - }); - it('succeeded', async () => { const model = await Model.create(data, cacheKey, modelConfig); diff --git a/src/test/unit/lib/model/common/PersistentStateMachine.test.js b/src/test/unit/lib/model/common/PersistentStateMachine.test.js index d63b5ea86..1a23ae22c 100644 --- a/src/test/unit/lib/model/common/PersistentStateMachine.test.js +++ b/src/test/unit/lib/model/common/PersistentStateMachine.test.js @@ -105,7 +105,7 @@ describe('PersistentStateMachine', () => { checkPSMLayout(psm); psm.init(); - expect(() => psm.gogo()).toThrowError('Transition requested while another transition is in progress: gogo'); + expect(() => psm.gogo()).toThrowError('Transition \'gogo\' requested while another transition is in progress'); }); @@ -160,31 +160,6 @@ describe('PersistentStateMachine', () => { }); describe('saveToCache', () => { - it('should be called after transition', async () => { - const psm = await PSM.create(data, cache, key, logger, smSpec); - checkPSMLayout(psm); - - // make transition from none -> start - await psm.init(); - - // check state - expect(psm.state).toEqual('start'); - - // check state propagation to data - expect(psm.context.data.currentState).toEqual('start'); - - // make transition from start -> end - await psm.gogo(); - - // check state change - expect(psm.state).toEqual('end'); - - // check what has been stored in cache - expect(cache.set).toBeCalledWith(key, psm.context.data); - - // check state propagation to `context.data` - expect(psm.context.data.currentState).toEqual('end'); - }); it('should rethrow error from cache.set', async () => { diff --git a/src/test/unit/mockLogger.js b/src/test/unit/mockLogger.js index 0cbb05a9f..f77baea07 100644 --- a/src/test/unit/mockLogger.js +++ b/src/test/unit/mockLogger.js @@ -14,7 +14,9 @@ function mockLogger(context, keepQuiet) { // if keepQuite is undefined then be quiet if(keepQuiet || typeof keepQuiet === 'undefined') { const log = { - log: jest.fn() + log: jest.fn(), + info: jest.fn(), + error: jest.fn() }; return { ...log, diff --git a/src/test/unit/outboundApi/handlers.test.js b/src/test/unit/outboundApi/handlers.test.js index 94038773d..fad9aea44 100644 --- a/src/test/unit/outboundApi/handlers.test.js +++ b/src/test/unit/outboundApi/handlers.test.js @@ -397,10 +397,7 @@ describe('Outbound API handlers:', () => { const mockContext = { request: { - body: { - transactionRequestId: '123', - the: 'body' - }, + body: {the: 'body', toParticipantId: 'pisp', transactionRequestId: '123'}, headers: { 'fspiop-source': 'foo' } @@ -429,15 +426,13 @@ describe('Outbound API handlers:', () => { expect(createSpy).toBeCalledTimes(1); const request = mockContext.request; const state = mockContext.state; - expect(createSpy).toBeCalledWith( - request.body, - `post_authorizations_${request.body.transactionRequestId}`, - { - cache: state.cache, - logger: state.logger, - wso2Auth: state.wso2Auth - } - ); + const cacheKey = `post_authorizations_${request.body.transactionRequestId}`; + const expectedConfig = { + cache: state.cache, + logger: state.logger, + wso2Auth: state.wso2Auth + }; + expect(createSpy).toBeCalledWith(request.body, cacheKey, expectedConfig); // run workflow expect(mockedPSM.run).toBeCalledTimes(1); From e8f4ed86b7a2296ccedc4f8c20402e67897805f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= Date: Thu, 16 Jul 2020 09:19:57 +0200 Subject: [PATCH 12/29] ci: checkout after installing default dependenices --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 49936d414..fa48857f1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,10 +66,10 @@ jobs: setup: executor: default-docker steps: - - checkout - run: name: Install general dependencies command: *defaults_Dependencies + - checkout - run: name: Access npm folder as root command: cd $(npm root -g)/npm @@ -87,10 +87,10 @@ jobs: test-unit: executor: default-docker steps: - - checkout - run: name: Install general dependencies command: *defaults_Dependencies + - checkout - restore_cache: keys: - dependency-cache-5-{{ checksum "src/package.json" }} @@ -129,10 +129,10 @@ jobs: lint: executor: default-docker steps: - - checkout - run: name: Install general dependencies command: *defaults_Dependencies + - checkout - restore_cache: keys: - dependency-cache-5-{{ checksum "src/package.json" }} From 03b7a7713b0654026a85410a2895270a5cb20b2c Mon Sep 17 00:00:00 2001 From: eoln <2881004+eoln@users.noreply.github.com> Date: Wed, 22 Jul 2020 13:11:47 +0200 Subject: [PATCH 13/29] fix: use anyOf instead of oneOf for PUT /authorizations/{id} at InboundServer (#187) --- src/InboundServer/api.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index 5667b37e3..2f38e353a 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -3022,7 +3022,7 @@ components: AuthenticationValue: title: AuthenticationValue - oneOf: + anyOf: - $ref: '#/components/schemas/OtpValue' - $ref: '#/components/schemas/QRCODE' - $ref: '#/components/schemas/U2FPinValue' From 99a4b98b7e20335e9a2c828abb8df604c6fb708e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= Date: Wed, 22 Jul 2020 13:16:04 +0200 Subject: [PATCH 14/29] chore: bump version --- src/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package.json b/src/package.json index 72026d316..fe5fd3af2 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@mojaloop/sdk-scheme-adapter", - "version": "10.6.0", + "version": "10.6.1", "description": "An adapter for connecting to Mojaloop API enabled switches.", "main": "index.js", "scripts": { From c4a0306c58ab52488c7467481d9bc110d6608e95 Mon Sep 17 00:00:00 2001 From: eoln <2881004+eoln@users.noreply.github.com> Date: Thu, 23 Jul 2020 08:17:47 +0200 Subject: [PATCH 15/29] fix: the way how errors are handled at InboundServer (#188) * fix: the way how errors are handled at InboundServer * lint: remove not used require --- package-lock.json | 3 +++ .../InboundThirdpartyTransactionModel.js | 19 +++++++++----- src/lib/model/InboundTransfersModel.js | 26 +++++++++---------- src/package.json | 2 +- 4 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..48e341a09 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3 @@ +{ + "lockfileVersion": 1 +} diff --git a/src/lib/model/InboundThirdpartyTransactionModel.js b/src/lib/model/InboundThirdpartyTransactionModel.js index 0012b5c9a..69e9ab59c 100644 --- a/src/lib/model/InboundThirdpartyTransactionModel.js +++ b/src/lib/model/InboundThirdpartyTransactionModel.js @@ -82,13 +82,18 @@ class InboundThirdpartyTransactionModel { let mojaloopErrorCode = Errors.MojaloopApiErrorCodes.INTERNAL_SERVER_ERROR; if (err instanceof HTTPResponseError) { const e = err.getData(); - if (e.res && e.res.body) { - try { - const bodyObj = JSON.parse(e.res.body); - mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${bodyObj.statusCode}`); - } - catch (ex) { - this._logger.push({ ex }).log('Error parsing error message body as JSON'); + if(e.res && (e.res.body || e.res.data)) { + if(e.res.body) { + try { + const bodyObj = JSON.parse(e.res.body); + mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${bodyObj.statusCode}`); + } catch(ex) { + // do nothing + this._logger.push({ ex }).log('Error parsing error message body as JSON'); + } + + } else if(e.res.data) { + mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${e.res.data.statusCode}`); } } } diff --git a/src/lib/model/InboundTransfersModel.js b/src/lib/model/InboundTransfersModel.js index 6cd61137b..81c4c81dc 100644 --- a/src/lib/model/InboundTransfersModel.js +++ b/src/lib/model/InboundTransfersModel.js @@ -22,7 +22,6 @@ const { } = require('@mojaloop/sdk-standard-components'); const shared = require('@internal/shared'); - /** * Models the operations required for performing inbound transfers */ @@ -667,22 +666,23 @@ class InboundTransfersModel { async _handleError(err) { let mojaloopErrorCode = Errors.MojaloopApiErrorCodes.INTERNAL_SERVER_ERROR; - - if(err instanceof HTTPResponseError) { + if (err instanceof HTTPResponseError) { const e = err.getData(); - if(e.res && e.res.body) { - try { - const bodyObj = JSON.parse(e.res.body); - mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${bodyObj.statusCode}`); - } - catch(ex) { - // do nothing - this._logger.push({ ex }).log('Error parsing error message body as JSON'); + if(e.res && (e.res.body || e.res.data)) { + if(e.res.body) { + try { + const bodyObj = JSON.parse(e.res.body); + mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${bodyObj.statusCode}`); + } catch(ex) { + // do nothing + this._logger.push({ ex }).log('Error parsing error message body as JSON'); + } + + } else if(e.res.data) { + mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${e.res.data.statusCode}`); } } - } - return new Errors.MojaloopFSPIOPError(err, null, null, mojaloopErrorCode).toApiErrorObject(); } } diff --git a/src/package.json b/src/package.json index fe5fd3af2..46f292670 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@mojaloop/sdk-scheme-adapter", - "version": "10.6.1", + "version": "10.6.2", "description": "An adapter for connecting to Mojaloop API enabled switches.", "main": "index.js", "scripts": { From 68e8de51a58417fe7d3fa44b9ef7dee10f40e780 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Thu, 30 Jul 2020 23:50:27 -0500 Subject: [PATCH 16/29] Added outbound GET/POST thirdparty requests transaction call. (#190) * Added outbound GET thirdparty requests transaction call. * Added post thirdparty requests transaction. * Fixed package.json and tests. * Renamed state. * Updated code. --- src/OutboundServer/api.yaml | 340 ++++++++++-- src/OutboundServer/handlers.js | 86 ++- .../OutboundThirdpartyTransactionModel.js | 258 ++++++++- .../model/common/PersistentStateMachine.js | 2 +- src/package.json | 2 +- .../@mojaloop/sdk-standard-components.js | 5 +- ...OutboundThirdpartyTransactionModel.test.js | 521 ++++++++++++++++++ 7 files changed, 1163 insertions(+), 51 deletions(-) create mode 100644 src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index 461fc7fc9..8576e357e 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -25,7 +25,7 @@ paths: - Health responses: 200: - description: Returns empty body if the scheme adapter outbound transfers service is running. + description: Returns empty body if the scheme adapter outbound transfers service is running. /transfers: post: summary: Sends money from one account to another @@ -148,12 +148,12 @@ paths: $ref: '#/components/responses/bulkTransferServerError' 504: $ref: '#/components/responses/bulkTransferTimeout' - + /bulkTransfers/{bulkTransferId}: get: summary: Retrieves information for a specific bulk transfer description: >- - The HTTP request `GET /bulkTransfers/{bulktTransferId}` is used to get information regarding a bulk transfer created or requested earlier. + The HTTP request `GET /bulkTransfers/{bulktTransferId}` is used to get information regarding a bulk transfer created or requested earlier. The `{bulkTransferId}` in the URI should contain the `bulkTransferId` that was used for the creation of the bulk transfer. tags: - BulkTransfers @@ -201,7 +201,7 @@ paths: get: summary: Retrieves information for a specific bulk quote description: >- - The HTTP request `GET /bulkQuotes/{bulktQuoteId}` is used to get information regarding a bulk quote created or requested earlier. + The HTTP request `GET /bulkQuotes/{bulktQuoteId}` is used to get information regarding a bulk quote created or requested earlier. The `{bulkQuoteId}` in the URI should contain the `bulkQuoteId` that was used for the creation of the bulk quote. tags: - BulkQuotes @@ -381,10 +381,61 @@ paths: # $ref: '#/components/responses/authorizationsPostServerError' # 504: # $ref: '#/components/responses/authorizationsPostTimeout' - - + /thirdpartyRequests/transactions: + post: + summary: Initiates a third party request transaction. + description: > + The HTTP request `POST /thirdpartyRequests/transactions` is sent to the Switch to initiate a third party request transaction. + tags: + - thirdpartyRequest + requestBody: + description: Thirdparty requests transaction request body. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/thirdpartyRequestsTransactionRequest' + responses: + 200: + $ref: '#/components/responses/thirdpartyRequestsTransactionResponse' + # 400: + # $ref: '#/components/responses/authorizationsPostBadRequest' + # 500: + # $ref: '#/components/responses/' + # 504: + # $ref: '#/components/responses/' + /thirdpartyRequests/transactions/{transactionRequestId}: + get: + summary: Retrieves information for a specific thirdparty request transaction. + description: > + The HTTP request `GET /thirdpartyRequest/transaction/{transactionRequestId}` is used to get information regarding a thirdparty transaction created or requested earlier. + The `{transactionRequestId}` in the URI should contain the `ID` that was used for the creation of the thirdparty request transaction. + tags: + - thirdpartyRequest + parameters: + - $ref: '#/components/parameters/transactionRequestId' + responses: + 200: + description: Thirdparty requests transaction information successfully retrieved + content: + application/json: + schema: + $ref: '#/components/responses/thirdpartyRequestsTransactionResponse' + # 500: + # description: + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/' components: schemas: + accountAddress: + title: accountAddress + type: string + description: Unique routable address which is DFSP specific. + pattern: ^([0-9A-Za-z_~\-\.]+[0-9A-Za-z_~\-])$ + minLength: 1 + maxLength: 1023 amountType: type: string @@ -575,7 +626,7 @@ components: message: type: string description: Error message text. - + errorTransferResponse: allOf: - $ref: '#/components/schemas/errorResponse' @@ -848,9 +899,7 @@ components: type: object properties: address: - type: string - minLength: 1 - maxLength: 1023 + $ref: '#/components/schemas/accountAddress' currency: type: string minLength: 3 @@ -1129,7 +1178,7 @@ components: $ref: '#/components/schemas/transferStatus' fulfil: $ref: '#/components/schemas/transferFulfilment' - + bulkTransferStatusResponse: type: object required: @@ -1147,7 +1196,7 @@ components: maxItems: 1000 items: $ref: '#/components/schemas/individualTransferFulfilment' - + bulkQuoteStatusResponse: type: object required: @@ -1230,7 +1279,7 @@ components: description: 'Optional extension, specific to deployment.' required: - transferState - + mojaloopAuthenticationType: title: AuthenticationType type: string @@ -1239,7 +1288,7 @@ components: - QRCODE - U2F description: Below are the allowed values for the enumeration AuthenticationType. - + mojaloopAuthenticationValue: title: AuthenticationValue oneOf: @@ -1247,7 +1296,7 @@ components: - $ref: '#/components/schemas/mojaloopQRCODE' - $ref: '#/components/schemas/mojaloopU2FPinValue' description: Contains the authentication value. The format depends on the authentication type used in the AuthenticationInfo complex type. - + mojaloopOtpValue: title: OtpValue type: string @@ -1267,15 +1316,15 @@ components: required: - pinValue - counter - + mojaloopU2FPIN: title: U2FPIN type: string pattern: ^\S{1,64}$ minLength: 1 maxLength: 64 - description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. - + description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. + mojaloopQRCODE: title: QRCODE type: string @@ -1283,7 +1332,7 @@ components: minLength: 1 maxLength: 64 description: QR code used as One Time Password. - + mojaloopAuthenticationInfo: title: AuthenticationInfo description: Data model for the complex type AuthenticationInfo @@ -1296,7 +1345,7 @@ components: $ref: '#/components/schemas/mojaloopAuthenticationType' authenticationValue: $ref: '#/components/schemas/mojaloopAuthenticationValue' - + individualTransferFulfilment: type: object description: A Mojaloop API transfer fulfilment for individual transfers in a bulk transfer @@ -1309,7 +1358,7 @@ components: extensionList: $ref: '#/components/schemas/extensionListComplex' description: 'Optional extension, specific to deployment.' - + mojaloopTransferState: type: string enum: @@ -1373,7 +1422,7 @@ components: required: - currency - amount - + mojaloopQuotesIDPutResponse: title: QuotesIDPutResponse description: 'PUT /quotes/{ID} object' @@ -1409,6 +1458,136 @@ components: extensionList: $ref: '#/components/schemas/extensionList' + mojaloopParty: + title: mojaloopParty + type: object + description: Data model for the complex type Party. + properties: + accounts: + $ref: '#/components/schemas/accountList' + description: List of accounts associated with the party containing and DFSP routable address, currency identifier and description. + partyIdInfo: + $ref: '#/components/schemas/mojaloopPartyIdInfo' + description: Party Id type, id, sub ID or type, and FSP Id. + merchantClassificationCode: + type: string + description: Used in the context of Payee Information, where the Payee happens to be a merchant accepting merchant payments. + example: 4321 + name: + type: string + description: Display name of the Party, could be a real name or a nick name. + example: Henrik Karlsson + personalInfo: + $ref: '#/components/schemas/mojaloopPartyPersonalInfo' + description: Personal information used to verify identity of Party such as first, middle, last name and date of birth. + required: + - partyIdInfo + + mojaloopPartyIdInfo: + title: mojaloopPartyIdInfo + type: object + description: Data model for the complex type PartyIdInfo. + properties: + partyIdType: + type: string + description: Type of the identifier. + example: PERSONAL_ID + partyIdentifier: + type: string + description: An identifier for the Party. + example: 16135551212 + partySubIdOrType: + type: string + description: A sub-identifier or sub-type for the Party. + example: DRIVING_LICENSE + fspId: + type: string + description: FSP ID (if known). + example: 1234 + required: + - partyIdType + - partyIdentifier + + mojaloopPartyPersonalInfo: + title: mojaloopPartyPersonalInfo + type: object + description: Data model for the complex type mojaloopPartyPersonalInfo. + properties: + complexName: + $ref: '#/components/schemas/mojaloopPartyComplexName' + description: First, middle and last name for the Party. + dateOfBirth: + type: string + description: Date of birth for the Party. + example: '1966-06-16' + + mojaloopPartyComplexName: + title: PartyComplexName + type: object + description: Data model for the complex type mojaloopPartyComplexName. + properties: + firstName: + type: string + description: Party’s first name. + example: Henrik + middleName: + type: string + description: Party’s middle name. + example: Johannes + lastName: + type: string + description: Party’s last name. + example: Karlsson + + mojaloopTransactionType: + title: TransactionType + type: object + description: Data model for the complex type mojaloopTransactionType. + properties: + scenario: + type: string + description: Deposit, withdrawal, refund, … + example: DEPOSIT + subScenario: + type: string + description: Possible sub-scenario, defined locally within the scheme. + example: Locally defined sub-scenario. + initiator: + type: string + description: Who is initiating the transaction - Payer or Payee. + example: PAYEE + initiatorType: + type: string + description: Consumer, agent, business, … + example: CONSUMER + refundInfo: + $ref: '#/components/schemas/mojaloopRefund' + description: Extra information specific to a refund scenario. Should only be populated if scenario is REFUND. + balanceOfPayments: + type: string + description: Balance of Payments code. + example: 123 + required: + - scenario + - initiator + - initiatorType + + mojaloopRefund: + title: mojaloopRefund + type: object + description: Data model for the complex type mojaloopRefund. + properties: + originalTransactionId: + type: string + description: Reference to the original transaction ID that is requested to be refunded. + example: b51ec534-ee48-4575-b6a9-ead2955b8069 + refundReason: + type: string + description: Free text indicating the reason for the refund. + example: Free text indicating reason for the refund. + required: + - originalTransactionId + transferError: type: object description: This object represents a Mojaloop API error received at any time during the transfer process @@ -1509,14 +1688,14 @@ components: ^(\+|-)?(?:90(?:(?:\.0{1,6})?)|(?:[0-9]|[1-8][0-9])(?:(?:\.[0-9]{1,6})?))$ description: > The API data type Latitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. longitude: type: string pattern: >- ^(\+|-)?(?:180(?:(?:\.0{1,6})?)|(?:[0-9]|[1-9][0-9]|1[0-7][0-9])(?:(?:\.[0-9]{1,6})?))$ description: >- The API data type Longitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. ilpCondition: type: string @@ -1593,13 +1772,13 @@ components: $ref: '#/components/schemas/accountsCreationState' lastError: $ref: '#/components/schemas/transferError' - + authorizationsRequest: title: authorizationsRequest description: POST /authorizations Request object type: object properties: - toParticipantId: + toParticipantId: type: string authenticationType: $ref: '#/components/schemas/mojaloopAuthenticationType' @@ -1621,11 +1800,11 @@ components: - transactionRequestId - quote additionalProperties: false - + authorizationsResponse: title: authorizationsResponse description: | - response body of POST/authorizations + response body of POST/authorizations derived from AuthorizationsIDPutResponse type: object required: @@ -1643,7 +1822,6 @@ components: type: string currentState: $ref: '#/components/schemas/authorizationsState' - authorizationsState: title: authorizationsState description: state of POST authorizations @@ -1652,7 +1830,7 @@ components: - WAITING_FOR_AUTHORIZATION_REQUEST - COMPLETED - ERROR_OCCURRED - + bulkTransferRequest: type: object required: @@ -1675,8 +1853,8 @@ components: items: $ref: '#/components/schemas/individualTransfer' extensions: - $ref: '#/components/schemas/extensionList' - + $ref: '#/components/schemas/extensionList' + individualTransfer: title: IndividualTransfer type: object @@ -1698,18 +1876,18 @@ components: maxLength: 128 type: string extensions: - $ref: '#/components/schemas/extensionList' + $ref: '#/components/schemas/extensionList' required: - transferId - to - amountType - currency - transactionType - + individualTransferResult: type: object properties: - transferId: + transferId: $ref: '#/components/schemas/mojaloopIdentifier' to: $ref: '#/components/schemas/transferParty' @@ -1764,7 +1942,7 @@ components: $ref: '#/components/schemas/individualQuote' extensions: $ref: '#/components/schemas/extensionList' - + individualQuote: title: IndividualQuote type: object @@ -1793,7 +1971,7 @@ components: - amountType - currency - transactionType - + bulkQuoteResponse: type: object required: @@ -1813,11 +1991,11 @@ components: items: $ref: '#/components/schemas/individualQuoteResult' description: List of individualQuoteResults in a bulk transfer response. - + individualQuoteResult: type: object properties: - quoteId: + quoteId: $ref: '#/components/schemas/mojaloopIdentifier' to: $ref: '#/components/schemas/transferParty' @@ -1837,7 +2015,7 @@ components: Object representing the last error to occur during a quote process. This may be a Mojaloop API error returned from another entity in the scheme or an object representing other types of error e.g. exceptions that may occur inside the scheme adapter. $ref: '#/components/schemas/quoteError' - + quoteError: type: object description: This object represents a Mojaloop API error received at any time during the quote process @@ -1858,7 +2036,69 @@ components: properties: bulkQuoteState: $ref: '#/components/schemas/bulkQuoteResponse' - + thirdpartyRequestsTransactionRequest: + title: thirdpartyRequestTransaction + type: object + description: Object. + properties: + transactionRequestId: + $ref: '#/components/schemas/mojaloopCorrelationId' + description: > + Common ID between the FSPs for the transaction request object. + The ID should be reused for resends of the same transaction request. + A new ID should be generated for each new transaction request. + sourceAccountId: + $ref: '#/components/schemas/accountAddress' + description: DFSP specific account identifiers, e.g. `dfspa.alice.1234` + consentId: + allOf: + - $ref: '#/components/schemas/mojaloopCorrelationId' + description: > + Common ID between the PISP and FSP for the Consent object + This tells DFSP and auth-service which constent allows the PISP to initiate transaction. + payee: + $ref: '#/components/schemas/mojaloopParty' + description: Information about the Payee in the proposed financial transaction. + payer: + $ref: '#/components/schemas/mojaloopParty' + description: Information about the Payer in the proposed financial transaction. + amountType: + $ref: '#/components/schemas/amountType' + description: SEND for sendAmount, RECEIVE for receiveAmount. + amount: + $ref: '#/components/schemas/mojaloopMoney' + description: Requested amount to be transferred from the Payer to Payee. + transactionType: + $ref: '#/components/schemas/mojaloopTransactionType' + description: Type of transaction. + expiration: + $ref: '#/components/schemas/timestamp' + description: > + Date and time until when the transaction request is valid. + It can be set to get a quick failure in case the peer FSP takes too long to respond. + example: '2016-05-24T08:38:08.699-04:00' + required: + - transactionRequestId + - sourceAccountId + - consentId + - payee + - payer + - amountType + - amount + - transactionType + - expiration + thirdpartyRequestsTransactionResponse: + title: thirdpartyTransactionResponse + type: object + description: The object sent in the PUT /thirdPartyRequests/transactions/{ID} request. + properties: + transactionId: + $ref: '#/components/schemas/mojaloopCorrelationId' + description: > + Identifies a related transaction (if a transaction has been created) + transactionRequestState: + $ref: '#/components/schemas/mojaloopTransactionRequestState' + description: State of the transaction request` responses: transferSuccess: description: Transfer completed successfully @@ -1973,10 +2213,16 @@ components: $ref: '#/components/schemas/errorTransferResponse' authorizationsResponse: description: authorization response - content: + content: application/json: - schema: + schema: $ref: '#/components/schemas/authorizationsResponse' + thirdpartyRequestsTransactionResponse: + description: Thirdparty requests transaction response + content: + application/json: + schema: + $ref: '#/components/schemas/thirdpartyRequestsTransactionResponse' parameters: transferId: @@ -2011,3 +2257,11 @@ components: pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ type: string description: Identifier of the merchant request to pay transfer to continue as returned in the response to a `POST /requestToPayTransfer` request. + transactionRequestId: + name: transactionRequestId + in: path + required: true + schema: + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + type: string + description: Identifier of the thirdparty transaction request. diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index e7f757898..5519f6b01 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -22,6 +22,7 @@ const { OutboundRequestToPayModel, OutboundBulkQuotesModel, OutboundAuthorizationsModel, + OutboundThirdpartyTransactionModel } = require('@internal/model'); @@ -85,9 +86,12 @@ const handleRequestToPayError = (method, err, ctx) => const handleRequestToPayTransferError = (method, err, ctx) => handleError(method, err, ctx, 'requestToPayTransferState'); -const handleAuthorizationsError = (method, err, ctx) => +const handleAuthorizationsError = (method, err, ctx) => handleError(method, err, ctx, 'authorizationsState'); +const handleThirdpartyRequestsTransactionsError = (method, err, ctx) => + handleError(method, err, ctx, 'thirdpartyRequestsTransactionsState'); + /** * Handler for outbound transfer request initiation */ @@ -447,6 +451,7 @@ const postAuthorizations = async (ctx) => { }; const cacheKey = `post_authorizations_${authorizationsRequest.transactionRequestId}`; + // use the authorizations model to execute asynchronous stages with the switch const model = await OutboundAuthorizationsModel.create(authorizationsRequest, cacheKey, modelConfig); @@ -456,12 +461,79 @@ const postAuthorizations = async (ctx) => { // return the result ctx.response.status = 200; ctx.response.body = response; - + } catch(err) { return handleAuthorizationsError('postAuthorizations', err, ctx); } }; +const getThirdpartyRequestsTransactions = async (ctx) => { + try { + // prepare request + const thirdpartyRequestsTransactionRequest = { + ...ctx.request.body, + currentState: 'getTransaction', + transactionRequestId: ctx.state.path.params.transactionRequestId, + }; + + // prepare config + const modelConfig = { + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }; + + const cacheKey = `get_thirdparty_requests_transactions_${thirdpartyRequestsTransactionRequest}`; + + // use the thirdparty requests transaction model to execute asynchronous stages with the switch + const model = await OutboundThirdpartyTransactionModel.create(thirdpartyRequestsTransactionRequest, cacheKey, modelConfig); + + // run model's workflow + const response = await model.run(); + + // return the result + ctx.response.status = 200; + ctx.response.body = response; + + } catch(err) { + return handleThirdpartyRequestsTransactionsError('getThirdpartyRequestsTransaction', err, ctx); + } +}; + +const postThirdpartyRequestsTransactions = async (ctx) => { + try { + // prepare request + const thirdpartyRequestsTransactionRequest = { + ...ctx.request.body, + currentState: 'postTransaction', + }; + + // prepare config + const modelConfig = { + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }; + + const cacheKey = `post_thirdparty_requests_transactions_${ctx.request.body.transactionRequestId}`; + + // use the thirdparty requests transaction model to execute asynchronous stages with the switch + const model = await OutboundThirdpartyTransactionModel.create(thirdpartyRequestsTransactionRequest, cacheKey, modelConfig); + + // run model's workflow + const response = await model.run(); + + // return the result + ctx.response.status = 200; + ctx.response.body = response; + + } catch(err) { + return handleThirdpartyRequestsTransactionsError('postThirdpartyRequestsTransaction', err, ctx); + } +}; + module.exports = { '/': { get: healthCheck @@ -497,7 +569,13 @@ module.exports = { '/requestToPayTransfer/{requestToPayTransactionId}': { put: putRequestToPayTransfer }, - '/authorizations' : { + '/authorizations': { post: postAuthorizations - } + }, + '/thirdpartyRequests/transactions/{transactionRequestId}': { + get: getThirdpartyRequestsTransactions + }, + '/thirdpartyRequests/transactions': { + post: postThirdpartyRequestsTransactions + }, }; diff --git a/src/lib/model/OutboundThirdpartyTransactionModel.js b/src/lib/model/OutboundThirdpartyTransactionModel.js index bb34f6aa6..727ca1954 100644 --- a/src/lib/model/OutboundThirdpartyTransactionModel.js +++ b/src/lib/model/OutboundThirdpartyTransactionModel.js @@ -10,6 +10,36 @@ 'use strict'; +const util = require('util'); +const { uuid } = require('uuidv4'); +const PSM = require('./common').PersistentStateMachine; +const ThirdpartyRequests = require('@mojaloop/sdk-standard-components').ThirdpartyRequests; + + +const specStateMachine = { + transitions: [ + { name: 'getThirdPartyTransaction', from: 'getTransaction', to: 'transactionSuccess' }, + { name: 'postThirdPartyTransaction', from: 'postTransaction', to: 'transactionSuccess' }, + { name: 'error', from: '*', to: 'errored' }, + ], + methods: { + // workflow methods + run, + getResponse, + + // specific transitions handlers methods + onGetThirdPartyTransaction, + onPostThirdPartyTransaction + } +}; + +const mapCurrentState = { + getTransaction: 'WAITING', + postTransaction: 'WAITING', + transactionSuccess: 'COMPLETED', + errored: 'ERROR_OCCURRED' +}; + function notificationChannel(id) { // mvp validation if (!(id && id.toString().length > 0)) { @@ -25,4 +55,230 @@ async function publishNotifications(cache, id, value) { return cache.publish(channel, value); } -module.exports = { notificationChannel, publishNotifications }; \ No newline at end of file +/** + * injects the config into state machine data + * so it will be accessible to on transition notification handlers via `this.handlersContext` + * + * @param {Object} config - config to be injected into state machine data + * @param {Object} specStateMachine - specState machine to be altered + * @returns {Object} - the altered specStateMachine + */ +function injectHandlersContext(config, specStateMachine) { + return { + ...specStateMachine, + data: { + handlersContext: { + config, // injects config property + requests: new ThirdpartyRequests({ + logger: config.logger, + peerEndpoint: config.peerEndpoint, + alsEndpoint: config.alsEndpoint, + dfspId: config.dfspId, + tls: config.tls, + jwsSign: config.jwsSign, + jwsSignPutParties: config.jwsSignPutParties, + jwsSigningKey: config.jwsSigningKey, + wso2Auth: config.wso2Auth + }) + } + } + }; +} + + +/** + * creates a new instance of state machine specified in specStateMachine ^ + * + * @param {Object} data - payload data + * @param {String} key - the cache key where state machine will store the payload data after each transition + * @param {Object} config - the additional configuration for transition handlers + */ +async function create(data, key, config) { + + if(!data.hasOwnProperty('transactionRequestId')) { + data.transactionRequestId = uuid(); + } + + const spec = injectHandlersContext(config, specStateMachine); + return PSM.create(data, config.cache, key, config.logger, spec); +} + + +/** + * loads state machine from cache by given key and specify the additional config for transition handlers + * @param {String} key - the cache key used to retrieve the state machine from cache + * @param {Object} config - the additional configuration for transition handlers + */ +async function loadFromCache(key, config) { + const customCreate = async (data, cache, key /**, logger, stateMachineSpec **/) => create(data, key, config); + return PSM.loadFromCache(config.cache, key, config.logger, specStateMachine, customCreate); +} + +/** + * runs the workflow + */ +async function run() { + const { data, logger } = this.context; + try { + // run transitions based on incoming state + switch(data.currentState) { + case 'getTransaction': + await this.getThirdPartyTransaction(); + logger.log(`GET Thirdparty transaction requested for ${data.transactionRequestId}, currentState: ${data.currentState}`); + break; + + case 'postTransaction': + await this.postThirdPartyTransaction(); + logger.log(`POST Thirdparty transaction requested for ${data.transactionRequestId}, currentState: ${data.currentState}`); + break; + + case 'transactionSuccess': + // all steps complete so return + logger.log('ThirdpartyTransaction completed successfully'); + return this.getResponse(); + + case 'errored': + // stopped in errored state + logger.log('State machine in errored state'); + return; + } + + logger.log(`Thirdparty request model state machine transition completed in state: ${this.state}. Recursing to handle next transition.`); + return this.run(); + + } catch (err) { + logger.log(`Error running ThirdPartyTransaction model: ${util.inspect(err)}`); + + // as this function is recursive, we don't want to error the state machine multiple times + if(data.currentState !== 'errored') { + // err should not have a transactionState property here! + if(err.transactionState) { + logger.log('State machine is broken'); + } + // transition to errored state + await this.error(err); + + // avoid circular ref between transactionState.lastError and err + err.transactionState = JSON.parse(JSON.stringify(this.getResponse())); + } + throw err; + } +} + +async function onGetThirdPartyTransaction() { + const { data, cache, logger } = this.context; + const { requests } = this.handlersContext; + const transferKey = notificationChannel(data.transactionRequestId); + let subId; + + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + try { + + subId = await cache.subscribe(transferKey, (cn, message, subId) => { + try { + const parsed = JSON.parse(message); + this.context.data = { + ...parsed.data, + currentState: this.state + }; + resolve(); + } catch(err) { + reject(err); + } finally { + if(subId) { + cache.unsubscribe(subId); + } + } + }); + + // Not sure what should be the destination FSP so using null for now. + const res = await requests.getThirdpartyRequestsTransactions(data.transactionRequestId, null); + logger.push({ res }).log('Thirdparty transaction request sent to peer'); + + } catch(error) { + logger.push(error).error('GET thirdparty transaction request error'); + if(subId) { + cache.unsubscribe(subId); + } + reject(error); + } + }); +} + +async function onPostThirdPartyTransaction() { + const { data, cache, logger } = this.context; + const { requests } = this.handlersContext; + const transferKey = notificationChannel(data.transactionRequestId); + let subId; + + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + try { + + subId = await cache.subscribe(transferKey, (cn, message, subId) => { + try { + const parsed = JSON.parse(message); + this.context.data = { + ...parsed.data, + currentState: this.state + }; + resolve(); + } catch(err) { + reject(err); + } finally { + if(subId) { + cache.unsubscribe(subId); + } + } + }); + + const request = { + ...data + }; + console.log(data); + // Request is routed to switch and then to the payer's fsp. + const res = await requests.postThirdpartyRequestsTransactions(request, data.payer.partyIdInfo.fspId); + logger.push({ res }).log('Thirdparty transaction request sent to peer'); + + } catch(error) { + logger.push(error).error('GET thirdparty transaction request error'); + if(subId) { + cache.unsubscribe(subId); + } + reject(error); + } + }); +} + +/** + * Returns an object representing the final state of the transaction request suitable for the outbound API + * + * @returns {object} - Response representing the result of the transaction request process + */ +function getResponse() { + const { data, logger } = this.context; + let resp = { ...data }; + + // project some of our internal state into a more useful + // representation to return to the SDK API consumer + resp.currentState = mapCurrentState[data.currentState]; + + // handle unexpected state + if(!resp.currentState) { + logger.log(`OutboundThirdpartyTransaction model response being returned from an unexpected state: ${data.currentState}. Returning ERROR_OCCURRED state`); + resp.currentState = mapCurrentState.errored; + } + + return resp; +} + +module.exports = { + create, + loadFromCache, + notificationChannel, + publishNotifications, + + // exports for testing purposes + mapCurrentState +}; diff --git a/src/lib/model/common/PersistentStateMachine.js b/src/lib/model/common/PersistentStateMachine.js index bff1253aa..5e5b4c74a 100644 --- a/src/lib/model/common/PersistentStateMachine.js +++ b/src/lib/model/common/PersistentStateMachine.js @@ -90,4 +90,4 @@ async function loadFromCache(cache, key, logger, stateMachineSpec, optCreate) { module.exports = { loadFromCache, create -}; \ No newline at end of file +}; diff --git a/src/package.json b/src/package.json index 46f292670..aff0d765f 100644 --- a/src/package.json +++ b/src/package.json @@ -29,7 +29,7 @@ "@internal/router": "file:lib/router", "@internal/shared": "file:lib/model/lib/shared", "@internal/validate": "file:lib/validate", - "@mojaloop/sdk-standard-components": "^10.6.1", + "@mojaloop/sdk-standard-components": "^10.6.8", "ajv": "^6.12.2", "co-body": "^6.0.0", "dotenv": "^8.2.0", diff --git a/src/test/__mocks__/@mojaloop/sdk-standard-components.js b/src/test/__mocks__/@mojaloop/sdk-standard-components.js index f567b6ec1..ca068d4ab 100644 --- a/src/test/__mocks__/@mojaloop/sdk-standard-components.js +++ b/src/test/__mocks__/@mojaloop/sdk-standard-components.js @@ -72,9 +72,12 @@ class MockThirdpartyRequests extends ThirdpartyRequests { super(...args); MockThirdpartyRequests.__instance = this; this.postAuthorizations = MockMojaloopRequests.__postAuthorizations; + this.getThirdpartyRequestsTransactions = MockThirdpartyRequests.__getThirdpartyRequestsTransactions; + this.postThirdpartyRequestsTransactions = MockThirdpartyRequests.__postThirdpartyRequestsTransactions; } } -MockMojaloopRequests.__postAuthorizations = jest.fn(() => Promise.resolve()); +MockThirdpartyRequests.__getThirdpartyRequestsTransactions = jest.fn(() => Promise.resolve()); +MockThirdpartyRequests.__postThirdpartyRequestsTransactions = jest.fn(() => Promise.resolve()); class MockIlp { constructor(config) { diff --git a/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js new file mode 100644 index 000000000..543d03deb --- /dev/null +++ b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js @@ -0,0 +1,521 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Kevin Leyow - kevin.leyow@modusbox.com * + **************************************************************************/ + +'use strict'; + +// we use a mock standard components lib to intercept and mock certain funcs +jest.mock('@mojaloop/sdk-standard-components'); + +const { uuid } = require('uuidv4'); +const Model = require('@internal/model').OutboundThirdpartyTransactionModel; +const { ThirdpartyRequests } = require('@mojaloop/sdk-standard-components'); +const defaultConfig = require('./data/defaultConfig'); +const mockLogger = require('../../mockLogger'); + + +describe('thirdpartyTransactionModel', () => { + let cacheKey; + let data; + let modelConfig; + + const subId = 123; + let handler = null; + + afterEach(() => { + ThirdpartyRequests.__getThirdpartyRequestsTransactions = jest.fn(() => Promise.resolve()); + }); + + /** + * + * @param {Object} opts + * @param {Number} opts.expirySeconds + * @param {Object} opts.delays + * @param {Number} delays.requestQuotes + * @param {Number} delays.prepareTransfer + * @param {Object} opts.rejects + * @param {boolean} rejects.quoteResponse + * @param {boolean} rejects.transferFulfils + */ + + + beforeEach(async () => { + modelConfig = { + logger: mockLogger({app: 'OutboundThirdpartyTransactionModel-test'}), + + // there is no need to mock redis but only Cache + cache: { + get: jest.fn(() => Promise.resolve(data)), + set: jest.fn(() => Promise.resolve), + + // mock subscription and store handler + subscribe: jest.fn(async (channel, h) => { + handler = jest.fn(h); + return subId; + }), + + // mock publish and call stored handler + publish: jest.fn(async (channel, message) => await handler(channel, message, subId)), + + unsubscribe: jest.fn(() => Promise.resolve()) + }, + ...defaultConfig + }; + data = { + currentState: 'getTransaction', + }; + }); + + describe('create', () => { + test('proper creation of model', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + expect(model.state).toBe('getTransaction'); + + // model's methods layout + const methods = [ + 'run', 'getResponse', + 'onGetThirdPartyTransaction', + 'onPostThirdPartyTransaction' + ]; + + methods.forEach((method) => expect(typeof model[method]).toEqual('function')); + }); + }); + + describe('loadFromCache', () => { + it('should load properly', async () => { + modelConfig.cache.get = jest.fn(() => Promise.resolve({source: 'yes I came from the cache'})); + + const model = await Model.loadFromCache(cacheKey, modelConfig); + expect(model.context.data.source).toEqual('yes I came from the cache'); + expect(model.context.cache.get).toBeCalledTimes(1); + expect(model.context.cache.get).toBeCalledWith(cacheKey); + }); + }); + + describe('getResponse', () => { + + it('should remap currentState', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + const states = model.allStates(); + + // should remap for all states except 'init' and 'none' + states.filter((s) => s !== 'init' && s !== 'none').forEach((state) => { + model.context.data.currentState = state; + const result = model.getResponse(); + expect(result.currentState).toEqual(Model.mapCurrentState[state]); + }); + + }); + + it('should handle unexpected state', async() => { + const model = await Model.create(data, cacheKey, modelConfig); + + // simulate lack of state by undefined property + delete model.context.data.currentState; + + const resp = model.getResponse(); + expect(resp.currentState).toEqual(Model.mapCurrentState.errored); + + // ensure that we log the problem properly + expect(modelConfig.logger.log).toBeCalledWith(`OutboundThirdpartyTransaction model response being returned from an unexpected state: ${undefined}. Returning ERROR_OCCURRED state`); + }); + }); + + describe('notificationChannel', () => { + it('should validate input', () => { + const invalidIds = [ + null, + undefined, + '' + ]; + invalidIds.forEach((id) => { + const invocation = () => Model.notificationChannel(id); + expect(invocation).toThrow('OutboundThirdpartyTransactionModel.notificationChannel: \'id\' parameter is required'); + }); + }); + + it('should generate proper channel name', () => { + const id = uuid(); + expect(Model.notificationChannel(id)).toEqual(`3ptrxnreq_${id}`); + }); + + }); + + describe('onGetThirdPartyTransaction', () => { + + it('should implement happy flow', async () => { + data.transactionRequestId = uuid(); + const channel = Model.notificationChannel(data.transactionRequestId); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + // mock workflow execution which is tested in separate case + model.run = jest.fn(() => Promise.resolve()); + + const message = { + data: { + 'transactionId': '5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5', + 'transactionRequestState': 'RECEIVED' + } + }; + + // manually invoke transition handler + model.onGetThirdPartyTransaction() + .then(() => { + // subscribe should be called only once + expect(cache.subscribe).toBeCalledTimes(1); + + // subscribe should be done to proper notificationChannel + expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); + + // check invocation of request.getThirdpartyRequestsTransactions + expect(ThirdpartyRequests.__getThirdpartyRequestsTransactions).toBeCalledWith(data.transactionRequestId, null); + + // check that this.context.data is updated + expect(model.context.data).toEqual({ + 'transactionId': '5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5', + 'transactionRequestState': 'RECEIVED', + + // current state will be updated by onAfterTransition which isn't called + // when manual invocation of transition handler happens + currentState: 'getTransaction' + }); + }); + + // ensure handler wasn't called before publishing the message + expect(handler).not.toBeCalled(); + + // ensure that cache.unsubscribe does not happened before fire the message + expect(cache.unsubscribe).not.toBeCalled(); + + + // fire publication with given message + await cache.publish(channel, JSON.stringify(message)); + + // handler should be called only once + expect(handler).toBeCalledTimes(1); + + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + + it('should unsubscribe from cache in case when error happens in workflow run', async () => { + data.transactionRequestId = uuid(); + const channel = Model.notificationChannel(data.transactionRequestId); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + // invoke transition handler + model.onGetThirdPartyTransaction().catch((err) => { + expect(err.message).toEqual('Unexpected token u in JSON at position 0'); + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + + // fire publication to channel with invalid message + // should throw the exception from JSON.parse + await cache.publish(channel, undefined); + + }); + + it('should unsubscribe from cache in case when error happens Mojaloop requests', async () => { + // simulate error + ThirdpartyRequests.__getThirdpartyRequestsTransactions = jest.fn(() => Promise.reject('getThirdPartyTransaction failed')); + data.transactionRequestId = uuid(); + + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + let theError = null; + // invoke transition handler + try { + await model.onGetThirdPartyTransaction(); + throw new Error('this point should not be reached'); + } catch (error) { + theError = error; + } + expect(theError).toEqual('getThirdPartyTransaction failed'); + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + }); + + describe('onPostThirdPartyTransaction', () => { + + it('should implement happy flow', async () => { + data.transactionRequestId = uuid(); + let payerInformation = { + 'personalInfo': { + 'complexName': { + 'firstName': 'Ayesha', + 'lastName': 'Takia' + } + }, + 'partyIdInfo': { + 'partyIdType': 'MSISDN', + 'partyIdentifier': '+44 8765 4321', + 'fspId': 'dfspa' + } + }; + data.payer = payerInformation; + const channel = Model.notificationChannel(data.transactionRequestId); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + // mock workflow execution which is tested in separate case + model.run = jest.fn(() => Promise.resolve()); + + const message = { + data: { + 'transactionId': '5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5', + 'transactionRequestState': 'RECEIVED' + } + }; + + // manually invoke transition handler + model.onPostThirdPartyTransaction() + .then(() => { + // subscribe should be called only once + expect(cache.subscribe).toBeCalledTimes(1); + + // subscribe should be done to proper notificationChannel + expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); + + // check invocation of request.postThirdpartyRequestsTransactions + expect(ThirdpartyRequests.__postThirdpartyRequestsTransactions).toBeCalledWith(data.transactionRequestId, 'dfspa'); + + // check that this.context.data is updated + expect(model.context.data).toEqual({ + Iam: 'the-body', + transactionRequestId: model.context.data.transactionRequestId, + payer: payerInformation, + // current state will be updated by onAfterTransition which isn't called + // when manual invocation of transition handler happens + currentState: 'postTransaction' + }); + }); + + // ensure handler wasn't called before publishing the message + expect(handler).not.toBeCalled(); + + // ensure that cache.unsubscribe does not happened before fire the message + expect(cache.unsubscribe).not.toBeCalled(); + + + // fire publication with given message + await cache.publish(channel, JSON.stringify(message)); + + // handler should be called only once + expect(handler).toBeCalledTimes(1); + + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + + it('should unsubscribe from cache in case when error happens in workflow run', async () => { + data.transactionRequestId = uuid(); + const channel = Model.notificationChannel(data.transactionRequestId); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + // invoke transition handler + model.onPostThirdPartyTransaction().catch((err) => { + expect(err.message).toEqual('Unexpected token u in JSON at position 0'); + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + + // fire publication to channel with invalid message + // should throw the exception from JSON.parse + await cache.publish(channel, undefined); + + }); + + it('should unsubscribe from cache in case when error happens Mojaloop requests', async () => { + // simulate error + ThirdpartyRequests.__postThirdpartyRequestsTransactions = jest.fn(() => Promise.reject('postThirdPartyTransaction failed')); + data.transactionRequestId = uuid(); + let payerInformation = { + 'personalInfo': { + 'complexName': { + 'firstName': 'Ayesha', + 'lastName': 'Takia' + } + }, + 'partyIdInfo': { + 'partyIdType': 'MSISDN', + 'partyIdentifier': '+44 8765 4321', + 'fspId': 'dfspa' + } + }; + data.payer = payerInformation; + + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + let theError = null; + // invoke transition handler + try { + await model.onPostThirdPartyTransaction(); + throw new Error('this point should not be reached'); + } catch (error) { + theError = error; + } + expect(theError).toEqual('postThirdPartyTransaction failed'); + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(subId); + }); + }); + + describe('run get thirdparty transaction workflow', () => { + + it('start', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.onGetThirdPartyTransaction = jest.fn(() => Promise.resolve({the: 'response'})); + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + const result = await model.run(); + expect(result).toEqual({the: 'response'}); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log.mock.calls).toEqual([ + ['State machine transitioned \'init\': none -> getTransaction'], + ['State machine transitioned \'getThirdPartyTransaction\': getTransaction -> transactionSuccess'], + [`GET Thirdparty transaction requested for ${data.transactionRequestId}, currentState: ${data.currentState}`], + ['Thirdparty request model state machine transition completed in state: transactionSuccess. Recursing to handle next transition.'], + ['ThirdpartyTransaction completed successfully'] + ]); + }); + + it('transactionSuccess', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'transactionSuccess'; + const result = await model.run(); + + expect(result).toEqual({the: 'response'}); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log).toBeCalledWith('ThirdpartyTransaction completed successfully'); + }); + + it('errored', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'errored'; + const result = await model.run(); + + expect(result).toBeFalsy(); + expect(model.getResponse).not.toBeCalled(); + expect(model.context.logger.log).toBeCalledWith('State machine in errored state'); + }); + + it('should handle errors', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getThirdPartyTransaction = jest.fn(() => { + const err = new Error('getTransaction failed'); + err.authorizationState = 'some'; + return Promise.reject(err); + }); + model.error = jest.fn(); + + let theError = null; + try { + await model.run(); + throw new Error('this point should not be reached'); + } catch(error) { + theError = error; + } + // check propagation of original error + expect(theError.message).toEqual('getTransaction failed'); + + // ensure we start transition to errored state + expect(model.error).toBeCalledTimes(1); + }); + }); + + describe('run post thirdparty transaction workflow', () => { + + it('start', async () => { + data.currentState = 'postTransaction'; + const model = await Model.create(data, cacheKey, modelConfig); + + model.onPostThirdPartyTransaction = jest.fn(() => Promise.resolve({the: 'response'})); + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + const result = await model.run(); + expect(result).toEqual({the: 'response'}); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log.mock.calls).toEqual([ + ['State machine transitioned \'init\': none -> postTransaction'], + ['State machine transitioned \'postThirdPartyTransaction\': postTransaction -> transactionSuccess'], + [`POST Thirdparty transaction requested for ${data.transactionRequestId}, currentState: ${data.currentState}`], + ['Thirdparty request model state machine transition completed in state: transactionSuccess. Recursing to handle next transition.'], + ['ThirdpartyTransaction completed successfully'] + ]); + }); + + it('transactionSuccess', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'transactionSuccess'; + const result = await model.run(); + + expect(result).toEqual({the: 'response'}); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log).toBeCalledWith('ThirdpartyTransaction completed successfully'); + }); + + it('errored', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'errored'; + const result = await model.run(); + + expect(result).toBeFalsy(); + expect(model.getResponse).not.toBeCalled(); + expect(model.context.logger.log).toBeCalledWith('State machine in errored state'); + }); + + it('should handle errors', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + model.getThirdPartyTransaction = jest.fn(() => { + const err = new Error('postTransaction failed'); + err.authorizationState = 'some'; + return Promise.reject(err); + }); + model.error = jest.fn(); + + let theError = null; + try { + await model.run(); + throw new Error('this point should not be reached'); + } catch(error) { + theError = error; + } + // check propagation of original error + expect(theError.message).toEqual('postTransaction failed'); + + // ensure we start transition to errored state + expect(model.error).toBeCalledTimes(1); + }); + }); +}); From ce5af8905ac29a30c1758b10c9aad2b4f060b16e Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Thu, 6 Aug 2020 23:22:34 -0500 Subject: [PATCH 17/29] fix(thirdparty): rename thirdParty to thirdparty (#192) * fix(thirdparty): rename thirdParty to thirdparty * chore: fix endpoints --- src/InboundServer/api.yaml | 46 +++++++++---------- src/InboundServer/handlers.js | 20 ++++---- src/OutboundServer/api.yaml | 4 +- .../OutboundThirdpartyTransactionModel.js | 2 +- src/test/unit/inboundApi/handlers.test.js | 18 ++++---- 5 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index 2f38e353a..b561c6e4b 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -1428,7 +1428,7 @@ paths: /authorizations: post: description: > - The HTTP request `POST /authorizations` is used to request the Payer to enter the + The HTTP request `POST /authorizations` is used to request the Payer to enter the applicable credentials in the PISP system. summary: Perform PISP authorization tags: @@ -1471,7 +1471,7 @@ paths: 501: $ref: '#/components/responses/ErrorResponse501' 503: - $ref: '#/components/responses/ErrorResponse503' + $ref: '#/components/responses/ErrorResponse503' '/authorizations/{ID}': parameters: - $ref: '#/components/parameters/ID' @@ -1544,7 +1544,7 @@ paths: application/json: authenticationInfo: authentication: U2F - authenticationValue: + authenticationValue: pinValue: '233133331' counter: '1' responseType: ENTERED @@ -2553,16 +2553,16 @@ paths: $ref: '#/components/responses/ErrorResponse503' requestBody: $ref: '#/components/requestBodies/ErrorInformationObject' - #thirdPartyRequests - /thirdPartyRequests/transactions/{ID}: + #thirdpartyRequests + /thirdpartyRequests/transactions/{ID}: put: description: > - The callback `PUT /thirdPartyRequests/transactions/{ID}` is used to inform the client of the result of a - previously-requested transaction. The `{ID}` in the URI should contain the `transactionRequestId` that was used for + The callback `PUT /thirdpartyRequests/transactions/{ID}` is used to inform the client of the result of a + previously-requested transaction. The `{ID}` in the URI should contain the `transactionRequestId` that was used for the creation of the transaction request. summary: Update third party transaction requests tags: - - thirdPartyRequests + - thirdpartyRequests operationId: UpdateThirdPartyTransactionRequests parameters: #Path @@ -2576,7 +2576,7 @@ paths: - $ref: '#/components/parameters/FSPIOP-Encryption' - $ref: '#/components/parameters/FSPIOP-Signature' - $ref: '#/components/parameters/FSPIOP-URI' - - $ref: '#/components/parameters/FSPIOP-HTTP-Method' + - $ref: '#/components/parameters/FSPIOP-HTTP-Method' - $ref: '#/components/parameters/Content-Length' requestBody: description: Transaction request result returned. @@ -2604,15 +2604,15 @@ paths: $ref: '#/components/responses/ErrorResponse501' 503: $ref: '#/components/responses/ErrorResponse503' - /thirdPartyRequest/transactions/{ID}/error: + /thirdpartyRequests/transactions/{ID}/error: put: description: > - If the server is unable to find the transaction request, or another processing error occurs, - the error callback `PUT /thirdPartyRequest/transactions/{ID}/error` is used. + If the server is unable to find the transaction request, or another processing error occurs, + the error callback `PUT /thirdpartyRequests/transactions/{ID}/error` is used. The `{ID}` in the URI should contain the `transactionRequestId` that was used for the creation of the transaction request. summary: Return transaction error tags: - - thirdPartyRequests + - thirdpartyRequests operationId: UpdateThirdPartyTransactionRequestsError parameters: #Path @@ -3019,7 +3019,7 @@ components: Below are the allowed values for the enumeration AuthenticationType. - OTP One-time password generated by the Payer FSP. - QRCODE QR code used as One Time Password. - + AuthenticationValue: title: AuthenticationValue anyOf: @@ -3047,16 +3047,16 @@ components: pattern: ^\S{1,64}$ minLength: 1 maxLength: 64 - description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. + description: U2F challenge-response, where payer FSP verifies if the response provided by end-user device matches the previously registered key. Counter: title: Counter $ref: '#/components/schemas/Integer' description: Sequential counter used for cloning detection. Present only for U2F authentication. RetriesLeft: title: RetriesLeft - $ref: '#/components/schemas/Integer' + $ref: '#/components/schemas/Integer' description: RetriesLeft is the number of retries left before the financial transaction is rejected. It must be expressed in the form of the data type Integer. retriesLeft=1 means that this is the last retry before the financial transaction is rejected. - + AuthorizationResponse: title: AuthorizationResponse type: string @@ -3722,10 +3722,10 @@ components: description: This is the transaction amount that will be withdrawn from the Payer’s account. transactionId: $ref: '#/components/schemas/CorrelationId' - description: Common ID (decided by the Payer FSP) between the FSPs for the future transaction object. The actual transaction will be created as part of a successful transfer process. + description: Common ID (decided by the Payer FSP) between the FSPs for the future transaction object. The actual transaction will be created as part of a successful transfer process. transactionRequestId: $ref: '#/components/schemas/CorrelationId' - description: The transactionRequestID, received from the POST /transactionRequests service earlier in the process. + description: The transactionRequestID, received from the POST /transactionRequests service earlier in the process. quote: $ref: '#/components/schemas/QuotesIDPutResponse' description: Quotes object @@ -3735,7 +3735,7 @@ components: - amount - transactionId - transactionRequestId - - quote + - quote AuthorizationsIDPutResponse: title: AuthorizationsIDPutResponse type: object @@ -4652,7 +4652,7 @@ components: ThirdPartyTransactionResponse: title: ThirdPartyTransactionResponse type: object - description: The object sent in the PUT /thirdPartyRequests/transactions/{ID} request. + description: The object sent in the PUT /thirdpartyRequests/transactions/{ID} request. properties: transactionId: $ref: '#/components/schemas/CorrelationId' @@ -4660,7 +4660,7 @@ components: Identifies a related transaction (if a transaction has been created) transactionRequestState: $ref: '#/components/schemas/TransactionRequestState' - description: State of the transaction request` + description: State of the transaction request` AccountId: type: string description: > @@ -4708,4 +4708,4 @@ components: description: Unique routable address which is DFSP specific. pattern: ^([0-9A-Za-z_~\-\.]+[0-9A-Za-z_~\-])$ minLength: 1 - maxLength: 1023 + maxLength: 1023 diff --git a/src/InboundServer/handlers.js b/src/InboundServer/handlers.js index d947f1729..dc72565d9 100644 --- a/src/InboundServer/handlers.js +++ b/src/InboundServer/handlers.js @@ -399,8 +399,8 @@ const putAuthorizationsById = async (ctx) => { } const idValue = ctx.state.path.params.ID; - - const authorizationChannel = ctx.state.conf.enablePISPMode + + const authorizationChannel = ctx.state.conf.enablePISPMode ? AuthorizationsModel.notificationChannel(idValue) : `otp_${ctx.state.path.params.ID}`; @@ -924,8 +924,8 @@ const putBulkTransfersByIdError = async(ctx) => { }; /** - * Handles PUT /thirdPartyRequests/transactions/{ID} request. - * This is response to a POST /thirdPartyRequests/transactions request + * Handles PUT /thirdpartyRequests/transactions/{ID} request. + * This is response to a POST /thirdpartyRequests/transactions request */ const putThirdPartyReqTransactionsById = async (ctx) => { if (ctx.state.conf.enableTestFeatures) { @@ -940,7 +940,7 @@ const putThirdPartyReqTransactionsById = async (ctx) => { // publish an event onto the cache for subscribers to action await ThirdpartyTrxnModelOut.publishNotifications(ctx.state.cache, ctx.state.path.params.ID, { - type: 'thirdPartyTransactionsReqResponse', + type: 'thirdpartyTransactionsReqResponse', data: ctx.request.body, headers: ctx.request.headers }); @@ -949,8 +949,8 @@ const putThirdPartyReqTransactionsById = async (ctx) => { }; /** - * Handles PUT /thirdPartyRequests/transactions/{ID}/error. - * This is error response to POST /thirdPartyRequests/transactions request + * Handles PUT /thirdpartyRequests/transactions/{ID}/error. + * This is error response to POST /thirdpartyRequests/transactions request */ const putThirdPartyReqTransactionsByIdError = async (ctx) => { if (ctx.state.conf.enableTestFeatures) { @@ -965,7 +965,7 @@ const putThirdPartyReqTransactionsByIdError = async (ctx) => { // publish an event onto the cache for subscribers to action await ThirdpartyTrxnModelOut.publishNotifications(ctx.state.cache, ctx.state.path.params.ID, { - type: 'thirdPartyTransactionsReqErrorResponse', + type: 'thirdpartyTransactionsReqErrorResponse', data: ctx.request.body, headers: ctx.request.headers }); @@ -1064,10 +1064,10 @@ module.exports = { '/transactionRequests/{ID}': { put: putTransactionRequestsById }, - '/thirdPartyRequests/transactions/{ID}': { + '/thirdpartyRequests/transactions/{ID}': { put: putThirdPartyReqTransactionsById }, - '/thirdPartyRequests/transactions/{ID}/error': { + '/thirdpartyRequests/transactions/{ID}/error': { put: putThirdPartyReqTransactionsByIdError } }; diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index 8576e357e..a8d8fe634 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -408,7 +408,7 @@ paths: get: summary: Retrieves information for a specific thirdparty request transaction. description: > - The HTTP request `GET /thirdpartyRequest/transaction/{transactionRequestId}` is used to get information regarding a thirdparty transaction created or requested earlier. + The HTTP request `GET /thirdpartyRequests/transactions/{transactionRequestId}` is used to get information regarding a thirdparty transaction created or requested earlier. The `{transactionRequestId}` in the URI should contain the `ID` that was used for the creation of the thirdparty request transaction. tags: - thirdpartyRequest @@ -2090,7 +2090,7 @@ components: thirdpartyRequestsTransactionResponse: title: thirdpartyTransactionResponse type: object - description: The object sent in the PUT /thirdPartyRequests/transactions/{ID} request. + description: The object sent in the PUT /thirdpartyRequests/transactions/{ID} request. properties: transactionId: $ref: '#/components/schemas/mojaloopCorrelationId' diff --git a/src/lib/model/OutboundThirdpartyTransactionModel.js b/src/lib/model/OutboundThirdpartyTransactionModel.js index 727ca1954..aa2eea6ce 100644 --- a/src/lib/model/OutboundThirdpartyTransactionModel.js +++ b/src/lib/model/OutboundThirdpartyTransactionModel.js @@ -236,7 +236,7 @@ async function onPostThirdPartyTransaction() { const request = { ...data }; - console.log(data); + // Request is routed to switch and then to the payer's fsp. const res = await requests.postThirdpartyRequestsTransactions(request, data.payer.partyIdInfo.fspId); logger.push({ res }).log('Thirdparty transaction request sent to peer'); diff --git a/src/test/unit/inboundApi/handlers.test.js b/src/test/unit/inboundApi/handlers.test.js index 4b524cb98..54d996ef2 100644 --- a/src/test/unit/inboundApi/handlers.test.js +++ b/src/test/unit/inboundApi/handlers.test.js @@ -84,7 +84,7 @@ describe('Inbound API handlers:', () => { }, response: {}, state: { - conf: {}, + conf: {}, logger: mockLogger({ app: 'inbound-handlers-unit-test' }) } }; @@ -126,7 +126,7 @@ describe('Inbound API handlers:', () => { logger: mockLogger({ app: 'inbound-handlers-unit-test' }), cache: { publish: async () => Promise.resolve(true) - } + } } }; }); @@ -174,7 +174,7 @@ describe('Inbound API handlers:', () => { logger: mockLogger({ app: 'inbound-handlers-unit-test' }), cache: { publish: async () => Promise.resolve(true) - } + } } }; }); @@ -283,7 +283,7 @@ describe('Inbound API handlers:', () => { logger: mockLogger({ app: 'inbound-handlers-unit-test' }), cache: { publish: async () => Promise.resolve(true) - } + } } }; }); @@ -331,7 +331,7 @@ describe('Inbound API handlers:', () => { logger: mockLogger({ app: 'inbound-handlers-unit-test' }), cache: { publish: async () => Promise.resolve(true) - } + } } }; }); @@ -584,7 +584,7 @@ describe('Inbound API handlers:', () => { }); }); - describe('PUT /thirdPartyRequests/transactions', () => { + describe('PUT /thirdpartyRequests/transactions', () => { let mockThirdPartyReqContext; beforeEach(() => { mockThirdPartyReqContext = { @@ -612,15 +612,15 @@ describe('Inbound API handlers:', () => { }; }); - test('calls `model.thirdPartyRequests.transactions` with the expected arguments.', async () => { + test('calls `model.thirdpartyRequests.transactions` with the expected arguments.', async () => { const pubNotificatiosnSpy = jest.spyOn(ThirdpartyTrxnModelOut, 'publishNotifications'); - await expect(handlers['/thirdPartyRequests/transactions/{ID}'].put(mockThirdPartyReqContext)).resolves.toBe(undefined); + await expect(handlers['/thirdpartyRequests/transactions/{ID}'].put(mockThirdPartyReqContext)).resolves.toBe(undefined); expect(pubNotificatiosnSpy).toHaveBeenCalledTimes(1); expect(pubNotificatiosnSpy).toHaveBeenCalledWith(mockThirdPartyReqContext.state.cache, mockThirdPartyReqContext.state.path.params.ID, { - type: 'thirdPartyTransactionsReqResponse', + type: 'thirdpartyTransactionsReqResponse', data: mockThirdPartyReqContext.request.body, headers: mockThirdPartyReqContext.request.headers }); From 58fc8b38e7fd03d1749463700ffe5d233cdb6f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= <2881004+eoln@users.noreply.github.com> Date: Thu, 8 Oct 2020 20:51:05 +0200 Subject: [PATCH 18/29] feat: 1671 kick off post authorization (#219) * feat(pisp-authorization): extend OutboundAuthorizationModel to call pisp authorization after receiving the quote * lint: fix smells --- src/OutboundServer/handlers.js | 2 +- src/lib/model/OutboundAuthorizationsModel.js | 8 +- .../OutboundRequestToPayTransferModel.js | 108 ++++++++++++++++-- src/lib/model/lib/requests/backendRequests.js | 4 + .../model/OutboundAuthorizationsModel.test.js | 9 +- 5 files changed, 109 insertions(+), 22 deletions(-) diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index 5519f6b01..e6b21ecd9 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -322,7 +322,7 @@ const postRequestToPayTransfer = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2Auth: ctx.state.wso2Auth }); // initialize the transfer model and start it running diff --git a/src/lib/model/OutboundAuthorizationsModel.js b/src/lib/model/OutboundAuthorizationsModel.js index 5ecdd9773..9eea29a26 100644 --- a/src/lib/model/OutboundAuthorizationsModel.js +++ b/src/lib/model/OutboundAuthorizationsModel.js @@ -135,11 +135,10 @@ async function onRequestAuthorization() { // in InboundServer/handlers is implemented putAuthorizationsById handler // where this event is fired but only if env ENABLE_PISP_MODE=true subId = await cache.subscribe(channel, async (channel, message, sid) => { - try { const parsed = JSON.parse(message); this.context.data = { - ...parsed.data, + ...parsed, currentState: this.state }; resolve(); @@ -147,7 +146,7 @@ async function onRequestAuthorization() { reject(err); } finally { if(sid) { - cache.unsubscribe(sid); + cache.unsubscribe(channel, sid); } } }); @@ -161,7 +160,7 @@ async function onRequestAuthorization() { } catch(error) { logger.push(error).error('Authorization request error'); if(subId) { - cache.unsubscribe(subId); + cache.unsubscribe(channel, subId); } reject(error); } @@ -190,7 +189,6 @@ function buildPostAuthorizationsRequest(data/** , config */) { * @returns {Object} - the altered specStateMachine */ function injectHandlersContext(config, specStateMachine) { - // TODO: postAuthorizations is a mocked method until this feature arrive in MojaloopRequests return { ...specStateMachine, data: { diff --git a/src/lib/model/OutboundRequestToPayTransferModel.js b/src/lib/model/OutboundRequestToPayTransferModel.js index e4231963c..e9320dc47 100644 --- a/src/lib/model/OutboundRequestToPayTransferModel.js +++ b/src/lib/model/OutboundRequestToPayTransferModel.js @@ -16,10 +16,12 @@ const StateMachine = require('javascript-state-machine'); const { Ilp, MojaloopRequests } = require('@mojaloop/sdk-standard-components'); const shared = require('@internal/shared'); const { BackendError } = require('./common'); - +const { BackendRequests } = require('@internal/requests'); +const OutboundAuthorizationsModel = require('./OutboundAuthorizationsModel.js'); const requestToPayTransferStateEnum = { 'WAITING_FOR_QUOTE_ACCEPTANCE': 'WAITING_FOR_QUOTE_ACCEPTANCE', 'WAITING_FOR_OTP_ACCEPTANCE': 'WAITING_FOR_OTP_ACCEPTANCE', + 'WAITING_FOR_AUTHORIZATION_ACCEPTANCE': 'WAITING_FOR_AUTHORIZATION_ACCEPTANCE', 'ERROR_OCCURRED': 'ERROR_OCCURRED', 'COMPLETED': 'COMPLETED', }; @@ -30,6 +32,7 @@ const requestToPayTransferStateEnum = { */ class OutboundRequestToPayTransferModel { constructor(config) { + this._config = { ...config }; this._cache = config.cache; this._logger = config.logger; this._requestProcessingTimeoutSeconds = config.requestProcessingTimeoutSeconds; @@ -58,9 +61,18 @@ class OutboundRequestToPayTransferModel { wso2Auth: config.wso2Auth }); + this._backendRequests = new BackendRequests({ + logger: this._logger, + backendEndpoint: config.backendEndpoint, + dfspId: config.dfspId + }); + this._ilp = new Ilp({ secret: config.ilpSecret }); + + this._enablePISPMode = config.enablePISPMode; + this._logger.info('enablePISPMode: ', this._enablePISPMode); } /** * Initializes the requestToPayTransfer model @@ -93,7 +105,9 @@ class OutboundRequestToPayTransferModel { transitions: [ { name: 'requestQuote', from: 'start', to: 'quoteReceived' }, { name: 'requestOTP', from: 'quoteReceived', to: 'otpReceived' }, + { name: 'requestAuthorization', from: 'quoteReceived', to: 'authorizationReceived' }, { name: 'executeTransfer', from: 'otpReceived', to: 'succeeded' }, + { name: 'executeAuthorizedTransfer', from: 'authorizationReceived', to: 'succeeded' }, { name: 'error', from: '*', to: 'errored' }, ], methods: { @@ -122,7 +136,10 @@ class OutboundRequestToPayTransferModel { // next transition is to requestQuote await this.stateMachine.requestQuote(); this._logger.log(`Quote received for transfer ${this.data.transferId}`); + + if(this.stateMachine.state === 'quoteReceived' && this.data.initiatorType === 'BUSINESS' && !this._autoAcceptR2PBusinessQuotes) { + // kick-off postAuthorizations here for PISP flow //we break execution here and return the quote response details to allow asynchronous accept or reject //of the quote await this._save(); @@ -131,19 +148,31 @@ class OutboundRequestToPayTransferModel { break; case 'quoteReceived': - // next transition is requestOTP - await this.stateMachine.requestOTP(); - if(this.data.initiatorType !== 'BUSINESS') { - this._logger.log(`OTP received for transactionId: ${this.data.requestToPayTransactionId} and transferId: ${this.data.transferId}`); - if(this.stateMachine.state === 'otpReceived' && !this._autoAcceptR2PDeviceOTP) { - //we break execution here and return the otp response details to allow asynchronous accept or reject - //of the quote - await this._save(); - return this.getResponse(); + // decide PISP or OTP flow + if (this._enablePISPMode) { + await this.stateMachine.requestAuthorization(); + await this._save(); + // let executeTransfer in recursive call of run() + } else { + await this.stateMachine.requestOTP(); + if (this.data.initiatorType !== 'BUSINESS') { + this._logger.log(`OTP received for transactionId: ${this.data.requestToPayTransactionId} and transferId: ${this.data.transferId}`); + if (this.stateMachine.state === 'otpReceived' && !this._autoAcceptR2PDeviceOTP) { + //we break execution here and return the otp response details to allow asynchronous accept or reject + //of the quote + await this._save(); + return this.getResponse(); + } } } break; + case 'authorizationReceived': + // next transition is executeTransfer + await this.stateMachine.executeAuthorizedTransfer(); + this._logger.log(`Transfer ${this.data.transferId} has been completed`); + break; + case 'otpReceived': // next transition is executeTransfer await this.stateMachine.executeTransfer(); @@ -163,7 +192,7 @@ class OutboundRequestToPayTransferModel { } // now call ourslves recursively to deal with the next transition - this._logger.log(`RequestToPay Transfer model state machine transition completed in state: ${this.stateMachine.state}. Recusring to handle next transition.`); + this._logger.log(`RequestToPay Transfer model state machine transition completed in state: ${this.stateMachine.state}. Recursing to handle next transition.`); return this.run(); } catch(err) { @@ -209,11 +238,16 @@ class OutboundRequestToPayTransferModel { // request a quote return this._requestQuote(); + case 'requestAuthorization': + // request an OTP + return this._requestAuthorization(); + case 'requestOTP': // request an OTP return this._requestOTP(); case 'executeTransfer': + case 'executeAuthorizedTransfer': // prepare a transfer and wait for fulfillment return this._executeTransfer(); @@ -465,6 +499,52 @@ class OutboundRequestToPayTransferModel { }); } + async _requestAuthorization() { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + // let use here OutboundAuthorizationsModel which allows to do synchronous call to get Authorization from PISP + this._logger.log('OutboundRequestToPayTransferModel._requestAuthorization'); + // prepare request + const authorizationsRequest = { + // here is hardcoded address of PISP because in this flow there is no way to get it so we are mocking it out + // take a look in thirdparty-scheme-adapter PISPTransactionModel(WIP) + toParticipantId: 'pisp', + authenticationType: 'U2F', + retriesLeft: '1', + amount: { + currency: 'USD', + amount: this.data.amount + }, + transactionId: this.data.transferId, + transactionRequestId: this.data.requestToPayTransactionId, + quote: { ...this.data.quoteResponse }, + }; + + const modelConfig = { ...this._config }; + + const cacheKey = `post_authorizations_${authorizationsRequest.transactionRequestId}`; + + // use the authorizations model to execute asynchronous stages with the switch + const model = await OutboundAuthorizationsModel.create(authorizationsRequest, cacheKey, modelConfig); + + // run model's workflow + + this.data.authorizationResponse = await model.run(); + // here is POC: happy flow + // the authorizationResponse should be analyzed + // and the pinValue should be validated but this is out of the scope of this POC + this._logger.push({ authorizationResponse: this.data.authorizationResponse }).log('authorizationResponse received'); + + // let call backend service which will validate pinValue + // TODO: add `/validate-authorization` path to mojaloop_simulator + // const validateResponse = await this._backendRequests.validateAuthorization(this.data.authorizationResponse); + // if (validateResponse.validationResult !== 'OK') { + // return reject(new Error('Invalid Authorization of Transaction')); + // } + resolve(this.data.authorizationResponse); + }); + } + /** * Sends request for * Starts the quote resolution process by sending a POST /quotes request to the switch; @@ -821,7 +901,11 @@ class OutboundRequestToPayTransferModel { case 'quoteReceived': resp.currentState = requestToPayTransferStateEnum.WAITING_FOR_QUOTE_ACCEPTANCE; break; - + + case 'authorizationReceived': + resp.currentState = requestToPayTransferStateEnum.WAITING_FOR_AUTHORIZATION_ACCEPTANCE; + break; + case 'otpReceived': resp.currentState = requestToPayTransferStateEnum.WAITING_FOR_OTP_ACCEPTANCE; break; diff --git a/src/lib/model/lib/requests/backendRequests.js b/src/lib/model/lib/requests/backendRequests.js index c7905fc7f..a5b4efd5e 100644 --- a/src/lib/model/lib/requests/backendRequests.js +++ b/src/lib/model/lib/requests/backendRequests.js @@ -46,6 +46,10 @@ class BackendRequests { return this._post('signchallenge', authorizationReq); } + async validateAuthorization(authorizationResponse) { + return this._post('validate-authorization', authorizationResponse); + } + /** * Executes a GET /otp request for the specified transaction request id * diff --git a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js index 09f2ba985..f9d7c35b5 100644 --- a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js +++ b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js @@ -202,7 +202,7 @@ describe('authorizationsModel', () => { // handler should unsubscribe from notification channel expect(cache.unsubscribe).toBeCalledTimes(1); - expect(cache.unsubscribe).toBeCalledWith(subId); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); }); it('should unsubscribe from cache in case when error happens in workflow run', async () => { @@ -215,7 +215,7 @@ describe('authorizationsModel', () => { model.onRequestAuthorization().catch((err) => { expect(err.message).toEqual('Unexpected token u in JSON at position 0'); expect(cache.unsubscribe).toBeCalledTimes(1); - expect(cache.unsubscribe).toBeCalledWith(subId); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); }); // fire publication to channel with invalid message @@ -228,7 +228,8 @@ describe('authorizationsModel', () => { // simulate error MojaloopRequests.__postAuthorizations = jest.fn(() => Promise.reject('postAuthorization failed')); data.transactionRequestId = uuid(); - + + const channel = Model.notificationChannel(data.transactionRequestId); const model = await Model.create(data, cacheKey, modelConfig); const { cache } = model.context; @@ -243,7 +244,7 @@ describe('authorizationsModel', () => { expect(theError).toEqual('postAuthorization failed'); // handler should unsubscribe from notification channel expect(cache.unsubscribe).toBeCalledTimes(1); - expect(cache.unsubscribe).toBeCalledWith(subId); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); }); }); From 472f3283004970136cb924b7c62c4fa830b75f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= Date: Thu, 8 Oct 2020 21:03:16 +0200 Subject: [PATCH 19/29] enforce --- src/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.js b/src/index.js index 14758c57d..842b135df 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ * James Bush - james.bush@modusbox.com * **************************************************************************/ + 'use strict'; const config = require('./config'); From e59582565463d0adde2e3fc79a605cf65306feb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= <2881004+eoln@users.noreply.github.com> Date: Wed, 21 Oct 2020 08:09:25 +0200 Subject: [PATCH 20/29] feat: PartiesModel (#221) * feat: PartiesModel * fix: some copy/paste leftovers --- src/lib/model/PartiesModel.js | 256 +++++++++++++++ src/lib/model/index.js | 3 +- src/package.json | 3 +- .../model/OutboundAuthorizationsModel.test.js | 13 - src/test/unit/lib/model/PartiesModel.test.js | 306 ++++++++++++++++++ 5 files changed, 566 insertions(+), 15 deletions(-) create mode 100644 src/lib/model/PartiesModel.js create mode 100644 src/test/unit/lib/model/PartiesModel.test.js diff --git a/src/lib/model/PartiesModel.js b/src/lib/model/PartiesModel.js new file mode 100644 index 000000000..473c1fa94 --- /dev/null +++ b/src/lib/model/PartiesModel.js @@ -0,0 +1,256 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +'use strict'; +const util = require('util'); + +const PSM = require('./common').PersistentStateMachine; +const MojaloopRequests = require('@mojaloop/sdk-standard-components').MojaloopRequests; + +const specStateMachine = { + init: 'start', + transitions: [ + { name: 'init', from: 'none', to: 'start' }, + { name: 'requestPartiesInformation', from: 'start', to: 'succeeded' }, + { name: 'error', from: '*', to: 'errored' }, + ], + methods: { + // workflow methods + run, + getResponse, + + // specific transitions handlers methods + onRequestPartiesInformation, + } +}; + +/** + * @name run + * @description run the workflow logic + * @param {string} type - the party type + * @param {string} id - the party id + * @param {string} [subId] - the optional party subId + * @returns {Object} - the http response payload + */ +async function run(type, id, subId) { + // input validation + const channel = channelName(type, id, subId); + if (channel === 'parties') { + throw new Error('PartiesModel.run required at least two string arguments: \'type\' and \'id\''); + } + + const { data, logger } = this.context; + try { + // run transitions based on incoming state + switch(data.currentState) { + case 'start': + // the first transition is requestPartiesInformation + await this.requestPartiesInformation(type, id, subId); + // don't await to finish the save + this.saveToCache(); + logger.log(`Party information requested for /${type}/${id}/${subId}, currentState: ${data.currentState}`); + + // eslint-disable-next-line no-fallthrough + case 'succeeded': + // all steps complete so return + logger.log('Party information retrieved successfully'); + return this.getResponse(); + + case 'errored': + // stopped in errored state + logger.log('State machine in errored state'); + return; + } + } catch (err) { + logger.log(`Error running Parties model: ${util.inspect(err)}`); + + // as this function is recursive, we don't want to error the state machine multiple times + if(data.currentState !== 'errored') { + // err should not have a requestPartiesInformationState property here! + if(err.requestPartiesInformationState) { + logger.log('State machine is broken'); + } + // transition to errored state + await this.error(err); + + // avoid circular ref between requestPartiesInformationState.lastError and err + err.requestPartiesInformationState = JSON.parse(JSON.stringify(this.getResponse())); + } + throw err; + } +} + +const mapCurrentState = { + start: 'WAITING_FOR_REQUEST_PARTY_INFORMATION', + succeeded: 'COMPLETED', + errored: 'ERROR_OCCURRED' +}; + +/** + * @name getResponse + * @description returns the http response payload depending on which state machine is + * @returns {Object} - the http response payload + */ +function getResponse() { + const { data, logger } = this.context; + let resp = { ...data }; + + // project some of our internal state into a more useful + // representation to return to the SDK API consumer + resp.currentState = mapCurrentState[data.currentState]; + + // handle unexpected state + if(!resp.currentState) { + logger.error(`Parties model response being returned from an unexpected state: ${data.currentState}. Returning ERROR_OCCURRED state`); + resp.currentState = mapCurrentState.errored; + } + + return resp; +} +/** + * @name onRequestPartiesInformation + * @description generates the pub/sub channel name + * @param {string} type - the party type + * @param {string} id - the party id + * @param {string} [subId] - the optional party subId + * @returns {string} - the pub/sub channel name + */ +async function onRequestPartiesInformation(type, id, subId) { + const { cache, logger } = this.context; + const { requests } = this.handlersContext; + const channel = channelName(type, id, subId); + let sid; + + // eslint-disable-next-line no-async-promise-executor + return new Promise( async(resolve, reject) => { + try { + // in InboundServer/handlers is implemented putPartiesById handler + sid = await cache.subscribe(channel, async (channel, message, sid) => { + // unsubscribe first + cache.unsubscribe(channel, sid); + + // protect against malformed JSON message + try { + const parsed = JSON.parse(message); + this.context.data = { + ...parsed, + currentState: this.state + }; + resolve(); + } catch(err) { + reject(err); + } + }); + + // GET /parties request to the switch + const res = await requests.getParties(type, id, subId); + + logger.push({ res }).log(' RequestPartiesInformation sent to peer'); + + } catch(error) { + logger.push(error).error('RequestPartiesInformation error'); + cache.unsubscribe(channel, sid); + reject(error); + } + }); +} + + +/** + * @name channelName + * @description generates the pub/sub channel name + * @param {string} type - the party type + * @param {string} id - the party id + * @param {string} [subId] - the optional party subId + * @returns {string} - the pub/sub channel name + */ +function channelName(type, id, subId) { + const tokens = ['parties', type, id, subId]; + return tokens.filter(t => !!t).join('-'); +} + + +/** + * @name generateKey + * @description generates the cache key used to store state machine + * @param {string} type - the party type + * @param {string} id - the party id + * @param {string} [subId] - the optional party subId + * @returns {string} - the cache key + */ +function generateKey(type, id, subId) { + return `key-${channelName(type, id, subId)}`; +} + + +/** + * @name injectHandlersContext + * @description injects the config into state machine data, so it will be accessible to on transition notification handlers via `this.handlersContext` + * @param {Object} config - config to be injected into state machine data + * @returns {Object} - the altered specStateMachine + */ +function injectHandlersContext(config) { + return { + ...specStateMachine, + data: { + handlersContext: { + config: { ...config }, // injects config property + requests: new MojaloopRequests({ + logger: config.logger, + peerEndpoint: config.peerEndpoint, + alsEndpoint: config.alsEndpoint, + dfspId: config.dfspId, + tls: config.tls, + jwsSign: config.jwsSign, + jwsSignPutParties: config.jwsSignPutParties, + jwsSigningKey: config.jwsSigningKey, + wso2Auth: config.wso2Auth + }) + } + } + }; +} + + +/** + * @name create + * @description creates a new instance of state machine specified in specStateMachine ^ + * @param {Object} data - payload data + * @param {String} key - the cache key where state machine will store the payload data after each transition + * @param {Object} config - the additional configuration for transition handlers + */ +async function create(data, key, config) { + const spec = injectHandlersContext(config, specStateMachine); + return PSM.create(data, config.cache, key, config.logger, spec); +} + + +/** + * @name loadFromCache + * @description loads state machine from cache by given key and specify the additional config for transition handlers + * @param {String} key - the cache key used to retrieve the state machine from cache + * @param {Object} config - the additional configuration for transition handlers + */ +async function loadFromCache(key, config) { + const customCreate = async (data, _cache, key) => create(data, key, config); + return PSM.loadFromCache(config.cache, key, config.logger, specStateMachine, customCreate); +} + + +module.exports = { + channelName, + create, + generateKey, + loadFromCache, + + // exports for testing purposes + mapCurrentState +}; + diff --git a/src/lib/model/index.js b/src/lib/model/index.js index 473e6c103..0a6be6258 100644 --- a/src/lib/model/index.js +++ b/src/lib/model/index.js @@ -23,7 +23,7 @@ const OutboundAuthorizationsModel = require('./OutboundAuthorizationsModel'); const InboundThirdpartyTransactionModel = require('./InboundThirdpartyTransactionModel'); const OutboundThirdpartyTransactionModel = require('./OutboundThirdpartyTransactionModel'); const { BackendError, PersistentStateMachine } = require('./common'); - +const PartiesModel = require('./PartiesModel'); module.exports = { AccountsModel, @@ -39,4 +39,5 @@ module.exports = { PersistentStateMachine, InboundThirdpartyTransactionModel, OutboundThirdpartyTransactionModel, + PartiesModel, }; diff --git a/src/package.json b/src/package.json index aff0d765f..26c14de58 100644 --- a/src/package.json +++ b/src/package.json @@ -11,7 +11,8 @@ }, "author": "Matt Kingston, James Bush, ModusBox Inc.", "contributors": [ - "Steven Oderayi " + "Steven Oderayi ", + "Paweł Marzec " ], "license": "Apache-2.0", "licenses": [ diff --git a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js index f9d7c35b5..01e89bad9 100644 --- a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js +++ b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js @@ -32,19 +32,6 @@ describe('authorizationsModel', () => { MojaloopRequests.__postAuthorizations = jest.fn(() => Promise.resolve()); }); - /** - * - * @param {Object} opts - * @param {Number} opts.expirySeconds - * @param {Object} opts.delays - * @param {Number} delays.requestQuotes - * @param {Number} delays.prepareTransfer - * @param {Object} opts.rejects - * @param {boolean} rejects.quoteResponse - * @param {boolean} rejects.transferFulfils - */ - - beforeEach(async () => { modelConfig = { logger: mockLogger({app: 'OutboundAuthorizationsModel-test'}), diff --git a/src/test/unit/lib/model/PartiesModel.test.js b/src/test/unit/lib/model/PartiesModel.test.js new file mode 100644 index 000000000..869221265 --- /dev/null +++ b/src/test/unit/lib/model/PartiesModel.test.js @@ -0,0 +1,306 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Paweł Marzec - pawel.marzec@modusbox.com * + **************************************************************************/ + +'use strict'; + +// we use a mock standard components lib to intercept and mock certain funcs +jest.mock('@mojaloop/sdk-standard-components'); + +const { uuid } = require('uuidv4'); +const Model = require('@internal/model').PartiesModel; +const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const defaultConfig = require('./data/defaultConfig'); +const mockLogger = require('../../mockLogger'); + +describe('PartiesModel', () => { + let cacheKey; + let data; + let modelConfig; + + const subId = 123; + let handler = null; + beforeEach(async () => { + + modelConfig = { + logger: mockLogger({app: 'PartiesModel-test'}), + + // there is no need to mock redis but only Cache + cache: { + get: jest.fn(() => Promise.resolve(data)), + set: jest.fn(() => Promise.resolve), + + // mock subscription and store handler + subscribe: jest.fn(async (channel, h) => { + handler = jest.fn(h); + return subId; + }), + + // mock publish and call stored handler + publish: jest.fn(async (channel, message) => await handler(channel, message, subId)), + + unsubscribe: jest.fn(() => Promise.resolve()) + }, + ...defaultConfig + }; + data = { the: 'mocked data' }; + + cacheKey = 'cache-key'; + }); + + describe('create', () => { + test('proper creation of model', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + + expect(model.state).toBe('start'); + + // model's methods layout + const methods = [ + 'run', 'getResponse', + 'onRequestPartiesInformation' + ]; + + methods.forEach((method) => expect(typeof model[method]).toEqual('function')); + }); + }); + + describe('getResponse', () => { + + it('should remap currentState', async () => { + const model = await Model.create(data, cacheKey, modelConfig); + const states = model.allStates(); + // should remap for all states except 'init' and 'none' + states.filter((s) => s !== 'init' && s !== 'none').forEach((state) => { + model.context.data.currentState = state; + const result = model.getResponse(); + expect(result.currentState).toEqual(Model.mapCurrentState[state]); + }); + + }); + + it('should handle unexpected state', async() => { + const model = await Model.create(data, cacheKey, modelConfig); + + // simulate lack of state by undefined property + delete model.context.data.currentState; + + const resp = model.getResponse(); + expect(resp.currentState).toEqual(Model.mapCurrentState.errored); + + // ensure that we log the problem properly + expect(modelConfig.logger.error).toHaveBeenCalledWith(`Parties model response being returned from an unexpected state: ${undefined}. Returning ERROR_OCCURRED state`); + }); + }); + + describe('channelName', () => { + it('should validate input', () => { + expect(Model.channelName()).toEqual('parties'); + }); + + it('should generate proper channel name', () => { + const type = uuid(); + const id = uuid(); + expect(Model.channelName(type, id)).toEqual(`parties-${type}-${id}`); + }); + }); + + describe('onRequestPartiesInformation', () => { + + it('should implement happy flow', async () => { + const type = uuid(); + const id = uuid(); + const subIdValue = uuid(); + + const channel = Model.channelName(type, id, subIdValue); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + // mock workflow execution which is tested in separate case + model.run = jest.fn(() => Promise.resolve()); + + const message = { + party: { + Iam: 'the-body' + } + }; + + // manually invoke transition handler + model.onRequestPartiesInformation(type, id, subIdValue) + .then(() => { + // subscribe should be called only once + expect(cache.subscribe).toBeCalledTimes(1); + + // subscribe should be done to proper notificationChannel + expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); + + // check invocation of request.getParties + expect(MojaloopRequests.__getParties).toBeCalledWith(type, id, subIdValue); + + // check that this.context.data is updated + expect(model.context.data).toEqual({ + ...message, + // current state will be updated by onAfterTransition which isn't called + // when manual invocation of transition handler happens + currentState: 'start' + }); + }); + + // ensure handler wasn't called before publishing the message + expect(handler).not.toBeCalled(); + + // ensure that cache.unsubscribe does not happened before fire the message + expect(cache.unsubscribe).not.toBeCalled(); + + + // fire publication with given message + await cache.publish(channel, JSON.stringify(message)); + + // handler should be called only once + expect(handler).toBeCalledTimes(1); + + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); + }); + + it('should unsubscribe from cache in case when error happens in workflow run', async () => { + const type = uuid(); + const id = uuid(); + const subIdValue = uuid(); + + const channel = Model.channelName(type, id, subIdValue); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + // invoke transition handler + model.onRequestPartiesInformation(type, id, subIdValue).catch((err) => { + expect(err.message).toEqual('Unexpected token u in JSON at position 0'); + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); + }); + + // fire publication to channel with invalid message + // should throw the exception from JSON.parse + await cache.publish(channel, undefined); + + }); + + it('should unsubscribe from cache in case when error happens Mojaloop requests', async () => { + // simulate error + MojaloopRequests.__getParties = jest.fn(() => Promise.reject('getParties failed')); + const type = uuid(); + const id = uuid(); + const subIdValue = uuid(); + + const channel = Model.channelName(type, id, subIdValue); + const model = await Model.create(data, cacheKey, modelConfig); + const { cache } = model.context; + + let theError = null; + // invoke transition handler + try { + await model.onRequestPartiesInformation(type, id, subIdValue); + throw new Error('this point should not be reached'); + } catch (error) { + theError = error; + } + expect(theError).toEqual('getParties failed'); + // handler should unsubscribe from notification channel + expect(cache.unsubscribe).toBeCalledTimes(1); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); + }); + + }); + + describe('run workflow', () => { + it('start', async () => { + const type = uuid(); + const id = uuid(); + const subIdValue = uuid(); + + const model = await Model.create(data, cacheKey, modelConfig); + + model.requestPartiesInformation = jest.fn(); + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'start'; + const result = await model.run(type, id, subIdValue); + expect(result).toEqual({the: 'response'}); + expect(model.requestPartiesInformation).toBeCalledTimes(1); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log.mock.calls).toEqual([ + ['State machine transitioned \'init\': none -> start'], + [`Party information requested for /${type}/${id}/${subIdValue}, currentState: start`], + ['Party information retrieved successfully'], + [`Persisted model in cache: ${cacheKey}`], + ]); + }); + it('succeeded', async () => { + const type = uuid(); + const id = uuid(); + const subIdValue = uuid(); + + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'succeeded'; + const result = await model.run(type, id, subIdValue); + + expect(result).toEqual({the: 'response'}); + expect(model.getResponse).toBeCalledTimes(1); + expect(model.context.logger.log).toBeCalledWith('Party information retrieved successfully'); + }); + + it('errored', async () => { + const type = uuid(); + const id = uuid(); + const subIdValue = uuid(); + + const model = await Model.create(data, cacheKey, modelConfig); + + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); + + model.context.data.currentState = 'errored'; + const result = await model.run(type, id, subIdValue); + + expect(result).toBeFalsy(); + expect(model.getResponse).not.toBeCalled(); + expect(model.context.logger.log).toBeCalledWith('State machine in errored state'); + }); + + it('should handle errors', async () => { + const type = uuid(); + const id = uuid(); + const subIdValue = uuid(); + + const model = await Model.create(data, cacheKey, modelConfig); + + model.requestPartiesInformation = jest.fn(() => { + const err = new Error('requestPartiesInformation failed'); + err.requestPartiesInformationState = 'some'; + return Promise.reject(err); + }); + model.error = jest.fn(); + model.context.data.currentState = 'start'; + + let theError = null; + try { + await model.run(type, id, subIdValue); + throw new Error('this point should not be reached'); + } catch(error) { + theError = error; + } + // check propagation of original error + expect(theError.message).toEqual('requestPartiesInformation failed'); + + // ensure we start transition to errored state + expect(model.error).toBeCalledTimes(1); + }); + }); +}); \ No newline at end of file From 876699f8285ab40e3727666ab0cebd92039b86e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= <2881004+eoln@users.noreply.github.com> Date: Wed, 21 Oct 2020 16:16:04 +0200 Subject: [PATCH 21/29] feat: 1771 parties - handlers (#222) * feat: PartiesModel * fix: some copy/paste leftovers * feat: implement and test PartiesModel handlers * test: /parties/{Type}/{ID}/{SubId} --- src/InboundServer/handlers.js | 17 +- src/OutboundServer/api.yaml | 603 +++++++++++++++++++ src/OutboundServer/handlers.js | 47 +- src/lib/model/PartiesModel.js | 4 +- src/test/unit/inboundApi/handlers.test.js | 78 +++ src/test/unit/lib/model/PartiesModel.test.js | 4 +- src/test/unit/outboundApi/handlers.test.js | 196 ++++++ 7 files changed, 942 insertions(+), 7 deletions(-) diff --git a/src/InboundServer/handlers.js b/src/InboundServer/handlers.js index dc72565d9..456b84f21 100644 --- a/src/InboundServer/handlers.js +++ b/src/InboundServer/handlers.js @@ -19,6 +19,8 @@ const Model = require('@internal/model').InboundTransfersModel; const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; const ThirdpartyTrxnModelOut = require('@internal/model').OutboundThirdpartyTransactionModel; +const PartiesModel = require('@internal/model').PartiesModel; + /** * Handles a GET /authorizations/{id} request */ @@ -491,9 +493,14 @@ const putPartiesByTypeAndId = async (ctx) => { const idValue = ctx.state.path.params.ID; const idSubValue = ctx.state.path.params.SubId; - // publish an event onto the cache for subscribers to action + // generate keys const cacheId = `${idType}_${idValue}` + (idSubValue ? `_${idSubValue}` : ''); + const channelName = PartiesModel.channelName(idType, idValue, idSubValue); + + // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(cacheId, ctx.request.body); + await ctx.state.cache.publish(channelName, ctx.request.body); + ctx.response.status = 200; }; @@ -592,9 +599,15 @@ const putPartiesByTypeAndIdError = async(ctx) => { // note that we publish the event the same way we publish a success PUT // the subscriber will notice the body contains an errorInformation property // and recognise it as an error response + // generate keys const cacheId = `${idType}_${idValue}` + (idSubValue ? `_${idSubValue}` : ''); - await ctx.state.cache.publish(cacheId, ctx.request.body); + const channelName = PartiesModel.channelName(idType, idValue, idSubValue); + // publish an event onto the cache for subscribers to action + await Promise.all([ + ctx.state.cache.publish(cacheId, ctx.request.body), + ctx.state.cache.publish(channelName, ctx.request.body) + ]); ctx.response.status = 200; ctx.response.body = ''; }; diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index a8d8fe634..cf6ab6e03 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -381,6 +381,7 @@ paths: # $ref: '#/components/responses/authorizationsPostServerError' # 504: # $ref: '#/components/responses/authorizationsPostTimeout' + /thirdpartyRequests/transactions: post: summary: Initiates a third party request transaction. @@ -404,6 +405,7 @@ paths: # $ref: '#/components/responses/' # 504: # $ref: '#/components/responses/' + /thirdpartyRequests/transactions/{transactionRequestId}: get: summary: Retrieves information for a specific thirdparty request transaction. @@ -427,6 +429,48 @@ paths: # application/json: # schema: # $ref: '#/components/schemas/' + + /parties/{Type}/{ID}: + parameters: + - $ref: '#/components/parameters/Type' + - $ref: '#/components/parameters/ID' + get: + description: >- + The HTTP request GET /parties// (or GET /parties///) is used to lookup + information regarding the requested Party, defined by , and optionally + (for example, GET /parties/MSISDN/123456789, or GET + /parties/BUSINESS/shoecompany/employee1). + summary: PartiesByTypeAndID + tags: + - parties + operationId: PartiesByTypeAndID + responses: + '200': + $ref: '#/components/responses/PartiesByIdResponse' + '404': + $ref: '#/components/responses/PartiesByIdError404' + + /parties/{Type}/{ID}/{SubId}: + parameters: + - $ref: '#/components/parameters/Type' + - $ref: '#/components/parameters/ID' + - $ref: '#/components/parameters/SubId' + get: + description: >- + The HTTP request GET /parties// (or GET /parties///) is used to lookup + information regarding the requested Party, defined by , and optionally + (for example, GET /parties/MSISDN/123456789, or GET + /parties/BUSINESS/shoecompany/employee1). + summary: PartiesSubIdByTypeAndID + tags: + - parties + operationId: PartiesSubIdByTypeAndID + responses: + '200': + $ref: '#/components/responses/PartiesByIdResponse' + '404': + $ref: '#/components/responses/PartiesByIdError404' + components: schemas: accountAddress: @@ -2099,6 +2143,519 @@ components: transactionRequestState: $ref: '#/components/schemas/mojaloopTransactionRequestState' description: State of the transaction request` + + Party: + title: Party + type: object + description: Data model for the complex type Party. + properties: + accounts: + $ref: '#/components/schemas/AccountList' + description: List of accounts associated with the party containing and DFSP routable address, currency identifier and description. + partyIdInfo: + $ref: '#/components/schemas/PartyIdInfo' + description: 'Party Id type, id, sub ID or type, and FSP Id.' + merchantClassificationCode: + $ref: '#/components/schemas/MerchantClassificationCode' + description: >- + Used in the context of Payee Information, where the Payee happens to + be a merchant accepting merchant payments. + name: + $ref: '#/components/schemas/PartyName' + description: 'Display name of the Party, could be a real name or a nick name.' + personalInfo: + $ref: '#/components/schemas/PartyPersonalInfo' + description: >- + Personal information used to verify identity of Party such as first, + middle, last name and date of birth. + required: + - partyIdInfo + + PartyName: + title: PartyName + type: string + minLength: 1 + maxLength: 128 + description: Name of the Party. Could be a real name or a nickname. + + PartyPersonalInfo: + title: PartyPersonalInfo + type: object + description: Data model for the complex type PartyPersonalInfo. + properties: + complexName: + $ref: '#/components/schemas/PartyComplexName' + description: 'First, middle and last name for the Party.' + dateOfBirth: + $ref: '#/components/schemas/DateOfBirth' + description: Date of birth for the Party. + + PartyComplexName: + title: PartyComplexName + type: object + description: Data model for the complex type PartyComplexName. + properties: + firstName: + $ref: '#/components/schemas/FirstName' + description: Party’s first name. + middleName: + $ref: '#/components/schemas/MiddleName' + description: Party’s middle name. + lastName: + $ref: '#/components/schemas/LastName' + description: Party’s last name. + + FirstName: + title: FirstName + type: string + minLength: 1 + maxLength: 128 + pattern: '^(?!\s*$)[\w .,''-]{1,128}$' + description: First name of the Party (Name Type). + + MiddleName: + title: MiddleName + type: string + minLength: 1 + maxLength: 128 + pattern: '^(?!\s*$)[\w .,''-]{1,128}$' + description: Middle name of the Party (Name Type). + + LastName: + title: LastName + type: string + minLength: 1 + maxLength: 128 + pattern: '^(?!\s*$)[\w .,''-]{1,128}$' + description: Last name of the Party (Name Type). + + DateOfBirth: + title: DateofBirth (type Date) + type: string + pattern: >- + ^(?:[1-9]\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)$ + description: Date of Birth of the Party. + + PartyIdInfo: + title: PartyIdInfo + type: object + description: Data model for the complex type PartyIdInfo. + properties: + partyIdType: + $ref: '#/components/schemas/PartyIdType' + description: Type of the identifier. + partyIdentifier: + $ref: '#/components/schemas/PartyIdentifier' + description: An identifier for the Party. + partySubIdOrType: + $ref: '#/components/schemas/PartySubIdOrType' + description: A sub-identifier or sub-type for the Party. + fspId: + $ref: '#/components/schemas/FspId' + description: FSP ID (if known) + extensionList: + $ref: '#/components/schemas/ExtensionList' + description: 'Optional extension, specific to deployment.' + required: + - partyIdType + - partyIdentifier + + PartyIdType: + title: PartyIdTypeEnum + type: string + enum: + - MSISDN + - EMAIL + - PERSONAL_ID + - BUSINESS + - DEVICE + - ACCOUNT_ID + - IBAN + - ALIAS + description: >- + Below are the allowed values for the enumeration - MSISDN An MSISDN + (Mobile Station International Subscriber Directory Number, that is, the + phone number) is used as reference to a participant. The MSISDN + identifier should be in international format according to the ITU-T + E.164 standard. Optionally, the MSISDN may be prefixed by a single plus + sign, indicating the international prefix. - EMAIL An email is used as + reference to a participant. The format of the email should be according + to the informational RFC 3696. - PERSONAL_ID A personal identifier is + used as reference to a participant. Examples of personal identification + are passport number, birth certificate number, and national registration + number. The identifier number is added in the PartyIdentifier element. + The personal identifier type is added in the PartySubIdOrType element. - + BUSINESS A specific Business (for example, an organization or a company) + is used as reference to a participant. The BUSINESS identifier can be in + any format. To make a transaction connected to a specific username or + bill number in a Business, the PartySubIdOrType element should be used. + - DEVICE A specific device (for example, a POS or ATM) ID connected to a + specific business or organization is used as reference to a Party. For + referencing a specific device under a specific business or organization, + use the PartySubIdOrType element. - ACCOUNT_ID A bank account number or + FSP account ID should be used as reference to a participant. The + ACCOUNT_ID identifier can be in any format, as formats can greatly + differ depending on country and FSP. - IBAN A bank account number or FSP + account ID is used as reference to a participant. The IBAN identifier + can consist of up to 34 alphanumeric characters and should be entered + without whitespace. - ALIAS An alias is used as reference to a + participant. The alias should be created in the FSP as an alternative + reference to an account owner. Another example of an alias is a username + in the FSP system. The ALIAS identifier can be in any format. It is also + possible to use the PartySubIdOrType element for identifying an account + under an Alias defined by the PartyIdentifier. + + PartyIdentifier: + title: PartyIdentifier + type: string + minLength: 1 + maxLength: 128 + description: Identifier of the Party. + + PartySubIdOrType: + title: PartySubIdOrType + type: string + minLength: 1 + maxLength: 128 + description: >- + Either a sub-identifier of a PartyIdentifier, or a sub-type of the + PartyIdType, normally a PersonalIdentifierType. + Currency: + title: CurrencyEnum + description: >- + The currency codes defined in ISO 4217 as three-letter alphabetic codes + are used as the standard naming representation for currencies. + type: string + minLength: 3 + maxLength: 3 + enum: + - AED + - AFN + - ALL + - AMD + - ANG + - AOA + - ARS + - AUD + - AWG + - AZN + - BAM + - BBD + - BDT + - BGN + - BHD + - BIF + - BMD + - BND + - BOB + - BRL + - BSD + - BTN + - BWP + - BYN + - BZD + - CAD + - CDF + - CHF + - CLP + - CNY + - COP + - CRC + - CUC + - CUP + - CVE + - CZK + - DJF + - DKK + - DOP + - DZD + - EGP + - ERN + - ETB + - EUR + - FJD + - FKP + - GBP + - GEL + - GGP + - GHS + - GIP + - GMD + - GNF + - GTQ + - GYD + - HKD + - HNL + - HRK + - HTG + - HUF + - IDR + - ILS + - IMP + - INR + - IQD + - IRR + - ISK + - JEP + - JMD + - JOD + - JPY + - KES + - KGS + - KHR + - KMF + - KPW + - KRW + - KWD + - KYD + - KZT + - LAK + - LBP + - LKR + - LRD + - LSL + - LYD + - MAD + - MDL + - MGA + - MKD + - MMK + - MNT + - MOP + - MRO + - MUR + - MVR + - MWK + - MXN + - MYR + - MZN + - NAD + - NGN + - NIO + - NOK + - NPR + - NZD + - OMR + - PAB + - PEN + - PGK + - PHP + - PKR + - PLN + - PYG + - QAR + - RON + - RSD + - RUB + - RWF + - SAR + - SBD + - SCR + - SDG + - SEK + - SGD + - SHP + - SLL + - SOS + - SPL + - SRD + - STD + - SVC + - SYP + - SZL + - THB + - TJS + - TMT + - TND + - TOP + - TRY + - TTD + - TVD + - TWD + - TZS + - UAH + - UGX + - USD + - UYU + - UZS + - VEF + - VND + - VUV + - WST + - XAF + - XCD + - XDR + - XOF + - XPF + - YER + - ZAR + - ZMW + - ZWD + Name: + title: Name + type: string + pattern: '^(?!\s*$)[\w .,''-]{1,128}$' + description: >- + The API data type Name is a JSON String, restricted by a regular + expression to avoid characters which are generally not used in a name. + Regular Expression - The regular expression for restricting the Name + type is "^(?!\s*$)[\w .,'-]{1,128}$". The restriction does not allow a + string consisting of whitespace only, all Unicode characters are + allowed, as well as the period (.) (apostrophe (‘), dash (-), comma (,) + and space characters ( ). Note - In some programming languages, Unicode + support must be specifically enabled. For example, if Java is used the + flag UNICODE_CHARACTER_CLASS must be enabled to allow Unicode + characters. + + FspId: + title: FspId + type: string + minLength: 1 + maxLength: 32 + description: FSP identifier. + + AccountList: + title: AccountList + type: object + description: Data model for the complex type AccountList + properties: + account: + type: array + items: + $ref: '#/components/schemas/Account' + minItems: 1 + maxItems: 32 + description: Accounts associated with the Party + required: + - account + + Account: + title: Account + type: object + description: Data model for the complex type Account + properties: + address: + $ref: '#/components/schemas/AccountAddress' + type: string + description: Unique routable address which is DFSP specific. + currency: + $ref: '#/components/schemas/Currency' + type: string + description: Currency of the amount. + description: + $ref: '#/components/schemas/Name' + type: string + description: The name of the account. + required: + - address + - currency + - description + AccountAddress: + title: AccountAddress + type: string + description: Unique routable address which is DFSP specific. + pattern: ^([0-9A-Za-z_~\-\.]+[0-9A-Za-z_~\-])$ + minLength: 1 + maxLength: 1023 + + PartiesCurrentState: + type: string + enum: + - 'WAITING_FOR_REQUEST_PARTY_INFORMATION' + - 'COMPLETED' + - 'ERROR_OCCURRED' + + MerchantClassificationCode: + title: MerchantClassificationCode + type: string + pattern: '^[\d]{1,4}$' + description: >- + A limited set of pre-defined numbers. This list would be a limited set + of numbers identifying a set of popular merchant types like School Fees, + Pubs and Restaurants, Groceries, etc. + + ErrorInformation: + title: ErrorInformation + type: object + description: Data model for the complex type ErrorInformation. + properties: + errorCode: + $ref: '#/components/schemas/ErrorCode' + description: Specific error number. + errorDescription: + $ref: '#/components/schemas/ErrorDescription' + description: Error description string. + extensionList: + $ref: '#/components/schemas/ExtensionList' + description: 'Optional list of extensions, specific to deployment.' + required: + - errorCode + - errorDescription + + ErrorCode: + title: ErrorCode + type: string + pattern: '^[1-9]\d{3}$' + description: >- + The API data type ErrorCode is a JSON String of four characters, + consisting of digits only. Negative numbers are not allowed. A leading + zero is not allowed. Each error code in the API is a four-digit number, + for example, 1234, where the first number (1 in the example) represents + the high-level error category, the second number (2 in the example) + represents the low-level error category, and the last two numbers (34 in + the example) represents the specific error. + + ErrorDescription: + title: ErrorDescription + type: string + minLength: 1 + maxLength: 128 + description: Error description string. + + ExtensionKey: + title: ExtensionKey + type: string + minLength: 1 + maxLength: 32 + description: Extension key. + + ExtensionValue: + title: ExtensionValue + type: string + minLength: 1 + maxLength: 128 + description: Extension value. + + Extension: + title: Extension + type: object + description: Data model for the complex type Extension + properties: + key: + $ref: '#/components/schemas/ExtensionKey' + description: Extension key. + value: + $ref: '#/components/schemas/ExtensionValue' + description: Extension value. + required: + - key + - value + + ExtensionList: + title: ExtensionList + type: object + description: Data model for the complex type ExtensionList + properties: + extension: + type: array + items: + $ref: '#/components/schemas/Extension' + minItems: 1 + maxItems: 16 + description: Number of Extension elements + required: + - extension + responses: transferSuccess: description: Transfer completed successfully @@ -2224,6 +2781,33 @@ components: schema: $ref: '#/components/schemas/thirdpartyRequestsTransactionResponse' + PartiesByIdResponse: + description: PartiesByIdResponse + content: + application/json: + schema: + type: object + description: 'GET /parties/{Type}/{ID} response object' + properties: + party: + $ref: '#/components/schemas/Party' + description: Information regarding the requested Party. + currentState: + $ref: '#/components/schemas/PartiesCurrentState' + required: + - party + - currentState + + PartiesByIdError404: + description: PartiesByIdError404 + content: + application/json: + schema: + type: object + properties: + errorInformation: + $ref: '#/components/schemas/ErrorInformation' + parameters: transferId: name: transferId @@ -2265,3 +2849,22 @@ components: pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ type: string description: Identifier of the thirdparty transaction request. + ID: + name: ID + in: path + required: true + schema: + type: string + Type: + name: Type + in: path + required: true + schema: + $ref: '#/components/schemas/PartyIdType' + SubId: + name: SubId + in: path + required: true + schema: + type: string + \ No newline at end of file diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index e6b21ecd9..4855cadc0 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -22,7 +22,8 @@ const { OutboundRequestToPayModel, OutboundBulkQuotesModel, OutboundAuthorizationsModel, - OutboundThirdpartyTransactionModel + OutboundThirdpartyTransactionModel, + PartiesModel } = require('@internal/model'); @@ -92,6 +93,10 @@ const handleAuthorizationsError = (method, err, ctx) => const handleThirdpartyRequestsTransactionsError = (method, err, ctx) => handleError(method, err, ctx, 'thirdpartyRequestsTransactionsState'); + +const handleRequestPartiesInformationError = (method, err, ctx) => + handleError(method, err, ctx, 'requestPartiesInformationState'); + /** * Handler for outbound transfer request initiation */ @@ -534,6 +539,40 @@ const postThirdpartyRequestsTransactions = async (ctx) => { } }; +const getPartiesByTypeAndId = async (ctx) => { + const type = ctx.state.path.params.Type; + const id = ctx.state.path.params.ID; + const subId = ctx.state.path.params.SubId; + + try { + // prepare config + const modelConfig = { + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2Auth: ctx.state.wso2Auth, + }; + + const cacheKey = PartiesModel.generateKey(type, id, subId); + + // use the authorizations model to execute asynchronous stages with the switch + const model = await PartiesModel.create({}, cacheKey, modelConfig); + + // run model's workflow + const response = await model.run(type, id, subId); + + // return the result + if (response.errorInformation) { + ctx.response.status = 404; + } else { + ctx.response.status = 200; + } + ctx.response.body = response; + } catch (err) { + return handleRequestPartiesInformationError('getPartiesByTypeAndId', err, ctx); + } +}; + module.exports = { '/': { get: healthCheck @@ -578,4 +617,10 @@ module.exports = { '/thirdpartyRequests/transactions': { post: postThirdpartyRequestsTransactions }, + '/parties/{Type}/{ID}': { + get: getPartiesByTypeAndId + }, + '/parties/{Type}/{ID}/{SubId}': { + get: getPartiesByTypeAndId + } }; diff --git a/src/lib/model/PartiesModel.js b/src/lib/model/PartiesModel.js index 473c1fa94..c98d32614 100644 --- a/src/lib/model/PartiesModel.js +++ b/src/lib/model/PartiesModel.js @@ -42,7 +42,7 @@ const specStateMachine = { async function run(type, id, subId) { // input validation const channel = channelName(type, id, subId); - if (channel === 'parties') { + if (channel.indexOf('-undefined-') != -1) { throw new Error('PartiesModel.run required at least two string arguments: \'type\' and \'id\''); } @@ -173,7 +173,7 @@ async function onRequestPartiesInformation(type, id, subId) { */ function channelName(type, id, subId) { const tokens = ['parties', type, id, subId]; - return tokens.filter(t => !!t).join('-'); + return tokens.map(x => `${x}`).join('-'); } diff --git a/src/test/unit/inboundApi/handlers.test.js b/src/test/unit/inboundApi/handlers.test.js index 54d996ef2..0c7c354b6 100644 --- a/src/test/unit/inboundApi/handlers.test.js +++ b/src/test/unit/inboundApi/handlers.test.js @@ -23,6 +23,7 @@ const mockLogger = require('../mockLogger'); const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; const ThirdpartyTrxnModelOut = require('@internal/model').OutboundThirdpartyTransactionModel; +const PartiesModel = require('@internal/model').PartiesModel; describe('Inbound API handlers:', () => { let mockArgs; @@ -627,6 +628,83 @@ describe('Inbound API handlers:', () => { }); }); + describe('PUT /parties/{Type}/{ID}', () => { + let mockPutPartiesCtx; + beforeEach(() => { + mockPutPartiesCtx = { + request: { + headers: { + 'fspiop-source': 'foo' + }, + body: { + party: { Iam: 'mocked-party' } + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'Type': 'MSISDN', + 'ID': '123456789' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), + cache: { + publish: jest.fn(() => Promise.resolve()) + }, + } + }; + }); + + test('calls cache.publish with the expected arguments.', async () => { + PartiesModel.channelName = jest.fn(() => 'mocked-parties-channel'); + await expect(handlers['/parties/{Type}/{ID}'].put(mockPutPartiesCtx)).resolves.toBe(undefined); + expect(mockPutPartiesCtx.state.cache.publish).toHaveBeenCalledWith('MSISDN_123456789', {party: { Iam: 'mocked-party' }}); + expect(mockPutPartiesCtx.state.cache.publish).toHaveBeenCalledWith('mocked-parties-channel', {party: { Iam: 'mocked-party' }}); + expect(mockPutPartiesCtx.response.status).toBe(200); + }); + }); + + describe('PUT /parties/{Type}/{ID}/{SubId}', () => { + let mockPutPartiesCtx; + beforeEach(() => { + mockPutPartiesCtx = { + request: { + headers: { + 'fspiop-source': 'foo' + }, + body: { + party: { Iam: 'mocked-party' } + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'Type': 'MSISDN', + 'ID': '123456789', + 'SubId': 'abcdefg' + } + }, + logger: mockLogger({ app: 'inbound-handlers-unit-test' }), + cache: { + publish: jest.fn(() => Promise.resolve()) + }, + } + }; + }); + + test('calls cache.publish with the expected arguments.', async () => { + PartiesModel.channelName = jest.fn(() => 'mocked-parties-channel'); + await expect(handlers['/parties/{Type}/{ID}'].put(mockPutPartiesCtx)).resolves.toBe(undefined); + expect(mockPutPartiesCtx.state.cache.publish).toHaveBeenCalledWith('MSISDN_123456789_abcdefg', {party: { Iam: 'mocked-party' }}); + expect(mockPutPartiesCtx.state.cache.publish).toHaveBeenCalledWith('mocked-parties-channel', {party: { Iam: 'mocked-party' }}); + expect(mockPutPartiesCtx.response.status).toBe(200); + }); + }); + }); function deepClone(obj) { diff --git a/src/test/unit/lib/model/PartiesModel.test.js b/src/test/unit/lib/model/PartiesModel.test.js index 869221265..369ee09c2 100644 --- a/src/test/unit/lib/model/PartiesModel.test.js +++ b/src/test/unit/lib/model/PartiesModel.test.js @@ -100,13 +100,13 @@ describe('PartiesModel', () => { describe('channelName', () => { it('should validate input', () => { - expect(Model.channelName()).toEqual('parties'); + expect(Model.channelName()).toEqual('parties-undefined-undefined-undefined'); }); it('should generate proper channel name', () => { const type = uuid(); const id = uuid(); - expect(Model.channelName(type, id)).toEqual(`parties-${type}-${id}`); + expect(Model.channelName(type, id)).toEqual(`parties-${type}-${id}-undefined`); }); }); diff --git a/src/test/unit/outboundApi/handlers.test.js b/src/test/unit/outboundApi/handlers.test.js index fad9aea44..b4aa97a7d 100644 --- a/src/test/unit/outboundApi/handlers.test.js +++ b/src/test/unit/outboundApi/handlers.test.js @@ -32,6 +32,7 @@ const { OutboundRequestToPayTransferModel, OutboundRequestToPayModel, OutboundAuthorizationsModel, + PartiesModel, } = require('@internal/model'); /** @@ -443,4 +444,199 @@ describe('Outbound API handlers:', () => { expect(mockContext.response.body).toEqual({ the: 'run response' }); }); }); + + describe('GET /parties/{Type}/{ID}/{SubId}', () => { + test('happy flow', async() => { + + const mockContext = { + request: {}, + response: {}, + state: { + conf: {}, + wso2Auth: 'mocked wso2Auth', + logger: mockLogger({ app: 'outbound-api-handlers-test'}), + cache: { the: 'mocked cache' }, + path: { + params: { + 'Type': 'MSISDN', + 'ID': '1234567890', + 'SubId': 'abcdefgh' + }, + }, + }, + }; + + // mock state machine + const mockedPSM = { + run: jest.fn(async () => ({ the: 'run response' })) + }; + + const createSpy = jest.spyOn(PartiesModel, 'create') + .mockImplementationOnce(async () => mockedPSM); + + // invoke handler + await handlers['/parties/{Type}/{ID}/{SubId}'].get(mockContext); + + // PSM model creation + const state = mockContext.state; + const cacheKey = PartiesModel.channelName('MSISDN', '1234567890', 'abcdefgh'); + const expectedConfig = { + cache: state.cache, + logger: state.logger, + wso2Auth: state.wso2Auth + }; + expect(createSpy).toBeCalledWith({}, cacheKey, expectedConfig); + + // run workflow + expect(mockedPSM.run).toBeCalledWith('MSISDN', '1234567890', 'abcdefgh'); + + // response + expect(mockContext.response.status).toBe(200); + expect(mockContext.response.body).toEqual({ the: 'run response' }); + }); + + test('error flow', async() => { + const mockContext = { + request: {}, + response: {}, + state: { + conf: {}, + wso2Auth: 'mocked wso2Auth', + logger: mockLogger({ app: 'outbound-api-handlers-test'}), + cache: { the: 'mocked cache' }, + path: { + params: { + 'Type': 'MSISDN', + 'ID': '1234567890', + 'SubId': 'abcdefgh' + }, + }, + }, + }; + + // mock state machine + const mockedPSM = { + run: jest.fn(async () => ({ errorInformation: { Iam: 'the-error'} })) + }; + + const createSpy = jest.spyOn(PartiesModel, 'create') + .mockImplementationOnce(async () => mockedPSM); + + // invoke handler + await handlers['/parties/{Type}/{ID}/{SubId}'].get(mockContext); + + // PSM model creation + const state = mockContext.state; + const cacheKey = PartiesModel.channelName('MSISDN', '1234567890', 'abcdefgh'); + const expectedConfig = { + cache: state.cache, + logger: state.logger, + wso2Auth: state.wso2Auth + }; + expect(createSpy).toBeCalledWith({}, cacheKey, expectedConfig); + + // run workflow + expect(mockedPSM.run).toBeCalledWith('MSISDN', '1234567890', 'abcdefgh'); + + // response + expect(mockContext.response.status).toBe(404); + expect(mockContext.response.body).toEqual({ errorInformation: { Iam: 'the-error'} }); + }); + }); + describe('GET /parties/{Type}/{ID}', () => { + test('happy flow', async() => { + + const mockContext = { + request: {}, + response: {}, + state: { + conf: {}, + wso2Auth: 'mocked wso2Auth', + logger: mockLogger({ app: 'outbound-api-handlers-test'}), + cache: { the: 'mocked cache' }, + path: { + params: { + 'Type': 'MSISDN', + 'ID': '1234567890' + }, + }, + }, + }; + + // mock state machine + const mockedPSM = { + run: jest.fn(async () => ({ the: 'run response' })) + }; + + const createSpy = jest.spyOn(PartiesModel, 'create') + .mockImplementationOnce(async () => mockedPSM); + + // invoke handler + await handlers['/parties/{Type}/{ID}'].get(mockContext); + + // PSM model creation + const state = mockContext.state; + const cacheKey = PartiesModel.channelName('MSISDN', '1234567890'); + const expectedConfig = { + cache: state.cache, + logger: state.logger, + wso2Auth: state.wso2Auth + }; + expect(createSpy).toBeCalledWith({}, cacheKey, expectedConfig); + + // run workflow + expect(mockedPSM.run).toBeCalledWith('MSISDN', '1234567890', undefined); + + // response + expect(mockContext.response.status).toBe(200); + expect(mockContext.response.body).toEqual({ the: 'run response' }); + }); + + test('error flow', async() => { + const mockContext = { + request: {}, + response: {}, + state: { + conf: {}, + wso2Auth: 'mocked wso2Auth', + logger: mockLogger({ app: 'outbound-api-handlers-test'}), + cache: { the: 'mocked cache' }, + path: { + params: { + 'Type': 'MSISDN', + 'ID': '1234567890' + }, + }, + }, + }; + + // mock state machine + const mockedPSM = { + run: jest.fn(async () => ({ errorInformation: { Iam: 'the-error'} })) + }; + + const createSpy = jest.spyOn(PartiesModel, 'create') + .mockImplementationOnce(async () => mockedPSM); + + // invoke handler + await handlers['/parties/{Type}/{ID}'].get(mockContext); + + // PSM model creation + const state = mockContext.state; + const cacheKey = PartiesModel.channelName('MSISDN', '1234567890'); + const expectedConfig = { + cache: state.cache, + logger: state.logger, + wso2Auth: state.wso2Auth + }; + expect(createSpy).toBeCalledWith({}, cacheKey, expectedConfig); + + // run workflow + expect(mockedPSM.run).toBeCalledWith('MSISDN', '1234567890', undefined); + + // response + expect(mockContext.response.status).toBe(404); + expect(mockContext.response.body).toEqual({ errorInformation: { Iam: 'the-error'} }); + }); + }); }); From cbd95098abb945c400cf80025dc036984f180884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= <2881004+eoln@users.noreply.github.com> Date: Thu, 22 Oct 2020 13:43:47 +0200 Subject: [PATCH 22/29] fix(parties-model): 1771 proper invocation of onRequestPartiesInformation (#223) * feat: PartiesModel * fix: some copy/paste leftovers * feat: implement and test PartiesModel handlers * test: /parties/{Type}/{ID}/{SubId} * fix(parties-model): onRequestPartiesInformation proper signature * lint: fix smells * fix: typo --- src/OutboundServer/handlers.js | 2 +- src/lib/model/PartiesModel.js | 3 ++- src/test/unit/lib/model/PartiesModel.test.js | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index 4855cadc0..dbb58fcd5 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -555,7 +555,7 @@ const getPartiesByTypeAndId = async (ctx) => { const cacheKey = PartiesModel.generateKey(type, id, subId); - // use the authorizations model to execute asynchronous stages with the switch + // use the parties model to execute asynchronous stages with the switch const model = await PartiesModel.create({}, cacheKey, modelConfig); // run model's workflow diff --git a/src/lib/model/PartiesModel.js b/src/lib/model/PartiesModel.js index c98d32614..1f0885c00 100644 --- a/src/lib/model/PartiesModel.js +++ b/src/lib/model/PartiesModel.js @@ -122,9 +122,10 @@ function getResponse() { * @param {string} [subId] - the optional party subId * @returns {string} - the pub/sub channel name */ -async function onRequestPartiesInformation(type, id, subId) { +async function onRequestPartiesInformation(fsm, type, id, subId) { const { cache, logger } = this.context; const { requests } = this.handlersContext; + logger.push({ type, id, subId }).error('onReqeustPartiesInformation - arguments'); const channel = channelName(type, id, subId); let sid; diff --git a/src/test/unit/lib/model/PartiesModel.test.js b/src/test/unit/lib/model/PartiesModel.test.js index 369ee09c2..787591d30 100644 --- a/src/test/unit/lib/model/PartiesModel.test.js +++ b/src/test/unit/lib/model/PartiesModel.test.js @@ -130,7 +130,7 @@ describe('PartiesModel', () => { }; // manually invoke transition handler - model.onRequestPartiesInformation(type, id, subIdValue) + model.onRequestPartiesInformation(model.fsm, type, id, subIdValue) .then(() => { // subscribe should be called only once expect(cache.subscribe).toBeCalledTimes(1); @@ -178,7 +178,7 @@ describe('PartiesModel', () => { const { cache } = model.context; // invoke transition handler - model.onRequestPartiesInformation(type, id, subIdValue).catch((err) => { + model.onRequestPartiesInformation(model.fsm, type, id, subIdValue).catch((err) => { expect(err.message).toEqual('Unexpected token u in JSON at position 0'); expect(cache.unsubscribe).toBeCalledTimes(1); expect(cache.unsubscribe).toBeCalledWith(channel, subId); @@ -204,7 +204,7 @@ describe('PartiesModel', () => { let theError = null; // invoke transition handler try { - await model.onRequestPartiesInformation(type, id, subIdValue); + await model.onRequestPartiesInformation(model.fsm, type, id, subIdValue); throw new Error('this point should not be reached'); } catch (error) { theError = error; From e9fc7af58a15afc6f8e1983f0a15bb5cc00f8198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= Date: Fri, 23 Oct 2020 15:15:05 +0200 Subject: [PATCH 23/29] remove package-lock --- package-lock.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 48e341a09..000000000 --- a/package-lock.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lockfileVersion": 1 -} From a68b628b558dffa928c6d2a3a1ffb3bf908f4dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marzec?= Date: Fri, 23 Oct 2020 15:35:33 +0200 Subject: [PATCH 24/29] enforce --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 26a7b0f14..74d9e6447 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ # Mojaloop SDK Scheme Adapter + This package provides a scheme adapter that interfaces between a Mojaloop API compliant switch and a DFSP backend platform that does not natively implement the Mojaloop API. The API between the scheme adapter and the DFSP backend is synchronous HTTP while the interface between the scheme adapter and the switch is native Mojaloop API. @@ -153,4 +154,4 @@ docker exec -it scheme-adapter-int sh -c 'npm run test:int' # copy results out docker cp scheme-adapter-int:/src/junit.xml . -``` \ No newline at end of file +``` From ea4582bccb6106635a66679982af2c1c87a3a8e2 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Wed, 30 Dec 2020 06:50:34 -0500 Subject: [PATCH 25/29] chore: sync pisp/master to v11.0.0 (#236) * Renamed ALS_ENDPOINT_HOST to ALS_ENDPOINT * Bumped up the version * #1325: Bulk quotes and bulk transfers support (#159) * Initial commit for bulk transfers support * Initial commit for bulk quotes and bulkt transfers support * Complete inbound API bulk quotes implementation * Separate bulk quotes switch base URL * Add postTransfers and getBulkTransfers * Update Outbound API spec with bulk transfers endpoints * Update outbound API spec * Add outbound bulk transfers * Fix API spec bugs * Add bulk party lookup * Whitespace fixes * Add outbound bulk quotes requests * Remove used imports * Add outbound POST bulk transfers handler * Add outboud GET /bulkTransfers/{ID} * Add Jest config for test debugging * Add unit tests for bulk quotes and bulk transfers * Add tests for outbound API. Fix missing specs entries. * Add tests for outbound API /bulkQuotes abd /bulkTransfers * Add inbound GET /bulkQuotes/{ID} * Add unit tests for inbound GET bulkQuotesById, PUT bulkQuotesById, PUT bulkQuotesErrorById * Fix spec description for /bulkTransfers and variable initialization in InboundTransfersModel * Remove unreferenced spec entry. * Updates per code review * Updates per code review * Updates per code review * Add more unit tests for InboundTransfersModel. Mor code review changes * Remodel bulk quotes using single state state-machine pattern * Remodel bulk transfers using state machine pattern. Add GET bulk quotes endpoint * Update API spec * Fix tests * Add more unit tests for InboundTransfersModel * Add unit tests for OutboundBulkQuotesModel * Add tests for OutboundBulkTransfersModel * Fix linting issue * PR review updates. * Remove some IDE auto inserted whitespaces * PR review update Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> * upstream merge * Fix misspelt environment variable * Bug fixes with golden path * Update unit test * Add bulk transfers and bulk quotes to the list of valid headers * added functionality for reserve notificaiton by payee * added enum for support of transfer reserve notification * removed local certs and keys * updated version * requests now respond with .data instead of .body (#182) * requests now respond with .data instead of .body * Fixed test. Fixed (not in the broken sense, but sort of) package version. * fixed lint errors * corrected version * Related to #182, no longer need to parse response data * Updated test * WIP * Basically functions. Needs a little tidy-up. * Made Cache constants read-only on the prototype. Set Redis config when in test mode. Tidied up websocket server creation. Removed koa-websocket and added ws. * Rearranged to make diff easier * Linting * Update sdk-standard-component * Now starting and stopping actual test server in unit tests to enable websocket tests. Added a little logging of client information to websocket connections and disconnections. Waiting for all websocket clients to close before concluding test server shutdown. Wrote tests for test server websockets. * Linting * Added support for specific callbacks and requests * Modified websocket endpoint names to match http endpoint names to streamline migration. Now printing entire x-forwarded-for header as client IP address list per review feedback. * Use process.exit instead of process.exitCode * Use process.exit instead of process.exitCode * Send the message ID to the client (#189) * Update FSPIOP API version * Update src/InboundServer/api.yaml Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> * changed inboud api * Delete launch.json * removed package-lock files * removed more package-locks * added missing header in validateHeaders * changed " to ' on refs * fixed missing # in refs in api.yaml * chore: fix faulty test * chore: update ci * chore: revert logger merge Co-authored-by: Vijay Kumar Co-authored-by: Yevhen Co-authored-by: Steven Oderayi Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> Co-authored-by: Valentin Co-authored-by: Rajiv Mothilal Co-authored-by: Matt Kingston Co-authored-by: Valentin Genev --- .circleci/config.yml | 21 +- src/InboundServer/api.yaml | 82 +++++- src/InboundServer/handlers.js | 115 +++++--- src/InboundServer/middlewares.js | 3 + src/OutboundServer/api.yaml | 9 +- src/OutboundServer/handlers.js | 2 +- src/TestServer/api.yaml | 2 +- src/TestServer/index.js | 133 ++++++++- src/config.js | 3 +- src/lib/cache/cache.js | 32 +- src/lib/model/InboundTransfersModel.js | 30 +- src/lib/model/lib/requests/backendRequests.js | 2 +- src/lib/model/lib/shared/index.js | 6 +- src/package.json | 5 +- src/test/unit/TestServer.test.js | 278 +++++++++++++++++- src/test/unit/data/defaultConfig.json | 1 + .../lib/model/InboundTransfersModel.test.js | 10 +- ...OutboundThirdpartyTransactionModel.test.js | 8 +- src/test/unit/outboundApi/handlers.test.js | 48 +-- 19 files changed, 660 insertions(+), 130 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fa48857f1..7775bac69 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,7 +15,7 @@ orbs: # defaults # # YAML defaults templates, in alphabetical order -## +## defaults_Dependencies: &defaults_Dependencies | apk --no-cache add git apk --no-cache add ca-certificates @@ -50,7 +50,7 @@ defaults_license_scanner: &defaults_license_scanner executors: default-docker: working_directory: /home/circleci/project - docker: + docker: - image: node:12.16.1-alpine default-machine: @@ -179,7 +179,7 @@ jobs: root: /tmp paths: - ./docker-image.tar - + license-scan: executor: default-machine steps: @@ -249,7 +249,7 @@ jobs: # failure_message: 'Anchore Image Scan failed for: \`"${DOCKER_ORG}/${CIRCLE_PROJECT_REPONAME}:${CIRCLE_TAG}"\`' # - store_artifacts: # path: anchore-reports - + publish: executor: default-machine steps: @@ -271,8 +271,17 @@ jobs: command: | echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG" docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG - echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" - docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG + case "$CIRCLE_TAG" in + *-pisp*) + # Don't update `late5t` for an image that has a `-pisp` + echo 'skipping late5t tag' + exit 0 + ;; + *) + echo "Publishing $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" + docker push $DOCKER_ORG/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG + ;; + esac - slack/status: webhook: "$SLACK_WEBHOOK_ANNOUNCEMENT" success_message: '*"${CIRCLE_PROJECT_REPONAME}"* - Release \`"${CIRCLE_TAG}"\` \nhttps://github.com/mojaloop/"${CIRCLE_PROJECT_REPONAME}"/releases/tag/"${CIRCLE_TAG}"' diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index b561c6e4b..51233ca14 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -1,9 +1,9 @@ openapi: 3.0.0 info: - version: '1.0' + version: '1.1' title: Open API for FSP Interoperability (FSPIOP) description: >- - Based on API Definition.docx updated on 2018-03-13 Version 1.0. Note - The + Based on API Definition.docx updated on 2020-05-19 Version 1.1. Note - The API supports a maximum size of 65536 bytes (64 Kilobytes) in the HTTP header. API supports a maximum size of 65536 bytes (64 Kilobytes) in the HTTP header. license: @@ -1718,6 +1718,46 @@ paths: schema: $ref: '#/components/schemas/TransfersIDPutResponse' required: true + patch: + description: >- + The HTTP request PATCH /transfers/ is used by a Switch to update the state of a previously reserved transfer, + if the Payee FSP has requested a commit notification when the Switch has completed processing of the transfer. + The in the URI should contain the transferId that was used for the creation of the transfer. + Please note that this request does not generate a callback. + summary: Return transfer information + tags: + - transfers + operationId: TransfersByIDPatch + parameters: + #Headers + - $ref: '#/components/parameters/Content-Length' + requestBody: + description: Transfer notification upon completion. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TransfersIDPatchResponse' + responses: + 200: + $ref: '#/components/responses/Response200' + 400: + $ref: '#/components/responses/ErrorResponse400' + 401: + $ref: '#/components/responses/ErrorResponse401' + 403: + $ref: '#/components/responses/ErrorResponse403' + 404: + $ref: '#/components/responses/ErrorResponse404' + 405: + $ref: '#/components/responses/ErrorResponse405' + 406: + $ref: '#/components/responses/ErrorResponse406' + 501: + $ref: '#/components/responses/ErrorResponse501' + 503: + $ref: '#/components/responses/ErrorResponse503' + '/transfers/{ID}/error': put: description: >- @@ -3019,7 +3059,6 @@ components: Below are the allowed values for the enumeration AuthenticationType. - OTP One-time password generated by the Payer FSP. - QRCODE QR code used as One Time Password. - AuthenticationValue: title: AuthenticationValue anyOf: @@ -4116,14 +4155,19 @@ components: ParticipantsTypeIDSubIDPostRequest: title: ParticipantsTypeIDSubIDPostRequest type: object - description: 'POST /participants/{Type}/{ID}/{SubId}, /participants/{Type}/{ID} object' + description: The object sent in the POST /participants/{Type}/{ID}/{SubId} and /participants/{Type}/{ID} requests. An additional optional ExtensionList element has been added as part of v1.1 changes. properties: fspId: $ref: '#/components/schemas/FspId' description: FSP Identifier that the Party belongs to. + example: 1234 currency: $ref: '#/components/schemas/Currency' description: Indicate that the provided Currency is supported by the Party. + example: USD + extensionList: + $ref: '#/components/schemas/ExtensionList' + description: Optional extension, specific to deployment. required: - fspId ParticipantsTypeIDPutResponse: @@ -4225,23 +4269,27 @@ components: PartyIdInfo: title: PartyIdInfo type: object - description: Data model for the complex type PartyIdInfo. + description: Data model for the complex type PartyIdInfo. An ExtensionList element has been added to this reqeust in version v1.1 properties: partyIdType: $ref: '#/components/schemas/PartyIdType' description: Type of the identifier. + example: PERSONAL_ID partyIdentifier: $ref: '#/components/schemas/PartyIdentifier' description: An identifier for the Party. + example: 16135551212 partySubIdOrType: $ref: '#/components/schemas/PartySubIdOrType' description: A sub-identifier or sub-type for the Party. + example: DRIVING_LICENSE fspId: $ref: '#/components/schemas/FspId' - description: FSP ID (if known) + description: FSP ID (if known). + example: 1234 extensionList: $ref: '#/components/schemas/ExtensionList' - description: 'Optional extension, specific to deployment.' + description: Optional extension, specific to deployment. required: - partyIdType - partyIdentifier @@ -4709,3 +4757,23 @@ components: pattern: ^([0-9A-Za-z_~\-\.]+[0-9A-Za-z_~\-])$ minLength: 1 maxLength: 1023 + TransfersIDPatchResponse: + title: TransfersIDPatchResponse + type: object + description: PATCH /transfers/{ID} object + properties: + completedTimestamp: + $ref: '#/components/schemas/DateTime' + description: Time and date when the transaction was completed. + example: "2020-05-19T08:38:08.699-04:00" + transferState: + $ref: '#/components/schemas/TransferState' + description: State of the transfer. + example: RESERVED + extensionList: + $ref: '#/components/schemas/ExtensionList' + description: Optional extension, specific to deployment. + required: + - completedTimestamp + - transferState + diff --git a/src/InboundServer/handlers.js b/src/InboundServer/handlers.js index 456b84f21..dbb49544e 100644 --- a/src/InboundServer/handlers.js +++ b/src/InboundServer/handlers.js @@ -29,12 +29,13 @@ const getAuthorizationsById = async (ctx) => { (async () => { try { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers }; - const res = await ctx.state.cache.set(`request_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing request : ${util.inspect(res)}`); + const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -73,12 +74,13 @@ const postAuthorizations = async (ctx) => { (async () => { try { if (ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`request_${ctx.request.body.transactionRequestId}`, req); + const res = await ctx.state.cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.transactionRequestId}`, req); ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -154,12 +156,13 @@ const getPartiesByTypeAndId = async (ctx) => { (async () => { try { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers }; - const res = await ctx.state.cache.set(`request_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing request : ${util.inspect(res)}`); + const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -210,13 +213,14 @@ const postQuotes = async (ctx) => { (async () => { try { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`request_${ctx.request.body.quoteId}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.quoteId}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -256,13 +260,14 @@ const postTransfers = async (ctx) => { (async () => { try { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`request_${ctx.request.body.transferId}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.transferId}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -348,13 +353,14 @@ const postTransactionRequests = async (ctx) => { (async () => { try { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`request_${ctx.request.body.transactionRequestId}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.transactionRequestId}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -391,12 +397,13 @@ const postTransactionRequests = async (ctx) => { */ const putAuthorizationsById = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } @@ -404,7 +411,7 @@ const putAuthorizationsById = async (ctx) => { const authorizationChannel = ctx.state.conf.enablePISPMode ? AuthorizationsModel.notificationChannel(idValue) - : `otp_${ctx.state.path.params.ID}`; + : `otp_${idValue}`; await ctx.state.cache.publish(authorizationChannel, { type: 'authorizationsResponse', @@ -420,12 +427,13 @@ const putAuthorizationsById = async (ctx) => { */ const putParticipantsById = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } @@ -444,12 +452,13 @@ const putParticipantsById = async (ctx) => { */ const putParticipantsByIdError = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } @@ -480,13 +489,14 @@ const putParticipantsByTypeAndId = async (ctx) => { */ const putPartiesByTypeAndId = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } const idType = ctx.state.path.params.Type; @@ -510,13 +520,14 @@ const putPartiesByTypeAndId = async (ctx) => { */ const putQuoteById = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -534,13 +545,14 @@ const putQuoteById = async (ctx) => { */ const putTransactionRequestsById = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -554,16 +566,17 @@ const putTransactionRequestsById = async (ctx) => { }; /** - * Handles a PUT /transfers/{ID}. This is a response to a POST|GET /transfers request + * Handles a PUT /transfers/{ID}. This is a response to a POST|PATCH|GET /transfers request */ const putTransfersById = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } @@ -582,13 +595,14 @@ const putTransfersById = async (ctx) => { */ const putPartiesByTypeAndIdError = async(ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } const idType = ctx.state.path.params.Type; @@ -618,13 +632,14 @@ const putPartiesByTypeAndIdError = async(ctx) => { */ const putQuotesByIdError = async(ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -643,13 +658,14 @@ const putQuotesByIdError = async(ctx) => { */ const putTransfersByIdError = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -676,7 +692,7 @@ const getBulkQuotesById = async (ctx) => { }; const res = await ctx.state.cache.set( `request_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -716,13 +732,14 @@ const postBulkQuotes = async (ctx) => { (async () => { try { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`request_${ctx.request.body.bulkQuoteId}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.bulkQuoteId}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -756,13 +773,14 @@ const postBulkQuotes = async (ctx) => { */ const putBulkQuotesById = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -780,13 +798,14 @@ const putBulkQuotesById = async (ctx) => { */ const putBulkQuotesByIdError = async(ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -853,13 +872,14 @@ const postBulkTransfers = async (ctx) => { (async () => { try { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`request_${ctx.request.body.bulkTransferId}`, req); - ctx.state.logger.log(`Cacheing request: ${util.inspect(res)}`); + const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.bulkTransferId}`, req); + ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); } // use the transfers model to execute asynchronous stages with the switch @@ -893,13 +913,14 @@ const postBulkTransfers = async (ctx) => { */ const putBulkTransfersById = async (ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -917,13 +938,14 @@ const putBulkTransfersById = async (ctx) => { */ const putBulkTransfersByIdError = async(ctx) => { if(ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Cacheing callback: ${util.inspect(res)}`); + const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); + ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } // publish an event onto the cache for subscribers to action @@ -942,12 +964,13 @@ const putBulkTransfersByIdError = async(ctx) => { */ const putThirdPartyReqTransactionsById = async (ctx) => { if (ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + const res = await ctx.state.cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } @@ -967,12 +990,13 @@ const putThirdPartyReqTransactionsById = async (ctx) => { */ const putThirdPartyReqTransactionsByIdError = async (ctx) => { if (ctx.state.conf.enableTestFeatures) { + const cache = ctx.state.cache; // we are in test mode so cache the request const req = { headers: ctx.request.headers, data: ctx.request.body }; - const res = await ctx.state.cache.set(`callback_${ctx.state.path.params.ID}`, req); + const res = await ctx.state.cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); } @@ -1066,7 +1090,8 @@ module.exports = { }, '/transfers/{ID}': { get: getTransfersById, - put: putTransfersById + put: putTransfersById, + patch: putTransfersById }, '/transfers/{ID}/error': { put: putTransfersByIdError diff --git a/src/InboundServer/middlewares.js b/src/InboundServer/middlewares.js index 1669ba142..052587389 100644 --- a/src/InboundServer/middlewares.js +++ b/src/InboundServer/middlewares.js @@ -49,8 +49,11 @@ const createHeaderValidator = (logger) => async (ctx, next) => { 'application/vnd.interoperability.parties+json;version=1.0', 'application/vnd.interoperability.participants+json;version=1.0', 'application/vnd.interoperability.quotes+json;version=1.0', + 'application/vnd.interoperability.bulkQuotes+json;version=1.0', + 'application/vnd.interoperability.bulkTransfers+json;version=1.0', 'application/vnd.interoperability.transactionRequests+json;version=1.0', 'application/vnd.interoperability.transfers+json;version=1.0', + 'application/vnd.interoperability.transfers+json;version=1.1', 'application/vnd.interoperability.authorizations+json;version=1.0', 'application/json' ]); diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index cf6ab6e03..e430971b0 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -25,7 +25,7 @@ paths: - Health responses: 200: - description: Returns empty body if the scheme adapter outbound transfers service is running. + description: Returns empty body if the scheme adapter outbound transfers service is running. /transfers: post: summary: Sends money from one account to another @@ -1142,6 +1142,7 @@ components: or an object representing other types of error e.g. exceptions that may occur inside the scheme adapter. $ref: '#/components/schemas/transferError' + transferResponse: type: object required: @@ -1732,14 +1733,14 @@ components: ^(\+|-)?(?:90(?:(?:\.0{1,6})?)|(?:[0-9]|[1-8][0-9])(?:(?:\.[0-9]{1,6})?))$ description: > The API data type Latitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. longitude: type: string pattern: >- ^(\+|-)?(?:180(?:(?:\.0{1,6})?)|(?:[0-9]|[1-9][0-9]|1[0-7][0-9])(?:(?:\.[0-9]{1,6})?))$ description: >- The API data type Longitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. ilpCondition: type: string @@ -2867,4 +2868,4 @@ components: required: true schema: type: string - \ No newline at end of file + diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index dbb58fcd5..ced6dfb46 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -327,7 +327,7 @@ const postRequestToPayTransfer = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth + wso2Auth: ctx.state.wso2Auth, }); // initialize the transfer model and start it running diff --git a/src/TestServer/api.yaml b/src/TestServer/api.yaml index 895199143..fe0f9915b 100644 --- a/src/TestServer/api.yaml +++ b/src/TestServer/api.yaml @@ -1,6 +1,6 @@ openapi: 3.0.0 info: - version: '1.0' + version: '1.1' title: Open API for FSP Interoperability (FSPIOP) description: >- Based on API Definition.docx updated on 2018-03-13 Version 1.0. Note - The diff --git a/src/TestServer/index.js b/src/TestServer/index.js index d283e6327..e9e70f4f3 100644 --- a/src/TestServer/index.js +++ b/src/TestServer/index.js @@ -9,6 +9,7 @@ **************************************************************************/ const Koa = require('koa'); +const ws = require('ws'); const https = require('https'); const http = require('http'); @@ -25,6 +26,15 @@ const router = require('@internal/router'); const handlers = require('./handlers'); const middlewares = require('../InboundServer/middlewares'); +const getWsIp = (req) => [ + req.socket.remoteAddress, + ...( + req.headers['x-forwarded-for'] + ? req.headers['x-forwarded-for'].split(/\s*,\s*/) + : [] + ) +]; + class TestServer { constructor(conf) { this._conf = conf; @@ -35,6 +45,9 @@ class TestServer { async setupApi() { this._api = new Koa(); + this._wsClients = new Map(); + this._wsapi = this._createWsServer(); + this._logger = await this._createLogger(); this._cache = await this._createCache(); @@ -65,19 +78,30 @@ class TestServer { async start() { await this._cache.connect(); + this._cache.subscribe(this._cache.EVENT_SET, this._handleCacheKeySet.bind(this)); + this._cache.setTestMode(true); if (!this._conf.testingDisableWSO2AuthStart) { await this._wso2Auth.start(); } - if (!this._conf.testingDisableServerStart) { - await new Promise((resolve) => this._server.listen(this._conf.testPort, resolve)); - this._logger.log(`Serving test API on port ${this._conf.testPort}`); - } + this._server.on('upgrade', (req, socket, head) => { + this._wsapi.handleUpgrade(req, socket, head, (ws) => + this._wsapi.emit('connection', ws, req)); + }); + await new Promise((resolve) => this._server.listen(this._conf.testPort, resolve)); + this._logger.log(`Serving test API on port ${this._conf.testPort}`); } async stop() { if (!this._server) { return; } + await new Promise(resolve => this._wsapi.close(resolve)); + // If we don't want for all clients to close before shutting down, the socket close + // handlers will be called after we return from this function, resulting in behaviour + // occurring after the server tells the user it has shutdown. + await Promise.all([...this._wsClients.keys()].map(socket => + new Promise(resolve => socket.on('close', resolve)) + )); await new Promise(resolve => this._server.close(resolve)); this._wso2Auth.stop(); await this._cache.disconnect(); @@ -127,8 +151,109 @@ class TestServer { } else { server = http.createServer(this._api.callback()); } + return server; } + + _createWsServer() { + const wss = new ws.Server({ noServer: true }); + + wss.on('error', err => { + // Curtains down + this._logger.push({ err }) + .log('Unhandled websocket error occurred. Shutting down.'); + process.exit(1); + }); + + wss.on('connection', (socket, req) => { + const logger = this._logger.push({ + url: req.url, + ip: getWsIp(req), + remoteAddress: req.socket.remoteAddress, + }); + logger.log('Websocket connection received'); + this._wsClients.set(socket, req); + socket.on('close', (code, reason) => { + logger.push({ code, reason }).log('Websocket connection closed'); + this._wsClients.delete(socket); + }); + }); + + return wss; + } + + // Send the received notification to subscribers where appropriate. + async _handleCacheKeySet(channel, key, id) { + const logger = this._logger.push({ key }); + logger.push({ channel, id }).log('Received Redis keyevent notification'); + + // Only notify clients of callback and request keyevents, as we don't want to encourage + // dependency on unintended behaviour (i.e. create a proxy Redis client by sending all + // keyevent notifications to the client). Some of this is implicitly performed later in + // this method, but we use the root path `/` to enable clients to subscribe to all events, + // so we filter them here. + const allowedPrefixes = [this._cache.CALLBACK_PREFIX, this._cache.REQUEST_PREFIX]; + if (!allowedPrefixes.some((prefix) => key.startsWith(prefix))) { + logger.push({ allowedPrefixes }) + .log('Notification not of allowed message type. Ignored.'); + return; + } + + // Map urls to callback prefixes. For example, as a user of this service, if I want to + // subscribe to Redis keyevent notifications with the prefix this._cache.REQUEST_PREFIX (at + // the time of writing, that's 'request_') then I'll connect to `ws://this-server/requests`. + // The map here defines that mapping, and exists to decouple the interface (the url) from + // the implementation (the "callback prefix"). + const endpoints = { + REQUEST: '/requests', + CALLBACK: '/callbacks', + }; + const urlToMsgPrefixMap = new Map([ + [endpoints.REQUEST, this._cache.REQUEST_PREFIX], + [endpoints.CALLBACK, this._cache.CALLBACK_PREFIX], + ]); + let keyData; // declare outside the loop here, then retrieve at most once + let keyDataStr; + for (let [socket, req] of this._wsClients) { + // If + // - the url is the catch-all root (i.e. `ws://this-server/`), or + // - the url corresponds (via urlToMsgPrefixMap) to the message prefix for this + // message. E.g. if the url is /callbacks and the key is + // `${this._cache.CALLBACK_PREFIX}whatever`, or + // - the url matches the key, e.g. we replace the url prefix with the key prefix and + // obtain a match. E.g. the url is /callbacks/hello and the key is callback_hello. + // send the message to the client. + const prefix = urlToMsgPrefixMap.get(req.url); + const urlMatchesPrefix = urlToMsgPrefixMap.has(req.url) && key.startsWith(prefix); + const urlMatchesKey = + req.url.replace(new RegExp(`^${endpoints.REQUEST}/`), this._cache.REQUEST_PREFIX) === key || + req.url.replace(new RegExp(`^${endpoints.CALLBACK}/`), this._cache.CALLBACK_PREFIX) === key; + if (req.url === '/' || urlMatchesPrefix || urlMatchesKey) { + if (!keyData || !keyDataStr) { + // Strip off the prefix and send the user the id + const requestId = [...urlToMsgPrefixMap.values()].reduce( + (key, prefix) => key.replace(new RegExp(`^${prefix}`), ''), + key + ); + keyData = await this._cache.get(key); + keyDataStr = JSON.stringify({ + ...keyData, + id: requestId, + }); + } + this._logger + .push({ + url: req.url, + key, + ip: getWsIp(req), + value: keyData, + prefix, + }) + .log('Pushing notification to subscribed client'); + socket.send(keyDataStr); + } + } + } } module.exports = TestServer; diff --git a/src/config.js b/src/config.js index ae873085c..c018674f2 100644 --- a/src/config.js +++ b/src/config.js @@ -122,7 +122,7 @@ module.exports = { logIndent: env.get('LOG_INDENT').default('2').asIntPositive(), - allowTransferWithoutQuote: env.get('allowTransferWithoutQuote').default('false').asBool(), + allowTransferWithoutQuote: env.get('ALLOW_TRANSFER_WITHOUT_QUOTE').default('false').asBool(), // for outbound transfers, allows an extensionList item in an error respone to be used instead // of the primary error code when setting the statusCode property on the synchronous response @@ -131,5 +131,6 @@ module.exports = { outboundErrorStatusCodeExtensionKey: env.get('OUTBOUND_ERROR_STATUSCODE_EXTENSION_KEY').asString(), proxyConfig: env.get('PROXY_CONFIG_PATH').asYamlConfig(), + reserveNotification: env.get('RESERVE_NOTIFICATION').default('false').asBool(), enablePISPMode: env.get('ENABLE_PISP_MODE').default('false').asBool() }; diff --git a/src/lib/cache/cache.js b/src/lib/cache/cache.js index 382f3ef0f..2456b8d8b 100644 --- a/src/lib/cache/cache.js +++ b/src/lib/cache/cache.js @@ -43,7 +43,6 @@ class Cache { this._callbackId = 0; } - /** * Connects to a redis server and waits for ready events * Note: We create two connections. One for get, set and publish commands @@ -63,6 +62,24 @@ class Cache { this._subscriptionClient.on('message', this._onMessage.bind(this)); } + /** + * Configure Redis to emit keyevent events. This corresponds to the application test mode, and + * enables us to listen for changes on callback_* and request_* keys. + * Docs: https://redis.io/topics/notifications + */ + async setTestMode(enable) { + // See for modes: https://redis.io/topics/notifications#configuration + // This mode, 'Es$' is: + // E Keyevent events, published with __keyevent@__ prefix. + // s Set commands + // $ String commands + const mode = enable ? 'Es$' : ''; + this._logger + .push({ 'notify-keyspace-events': mode }) + .log('Configuring Redis to emit keyevent events'); + this._client.config('SET', 'notify-keyspace-events', mode); + } + async disconnect() { if (!this._connected) { return; @@ -149,7 +166,13 @@ class Cache { // call the callback with the channel name, message and callbackId... // ...(which is useful for unsubscribe) - this._callbacks[channel][k](channel, msg, k); + try { + this._callbacks[channel][k](channel, msg, k); + } catch (err) { + this._logger + .push({ callbackId: k, err }) + .log('Unhandled error in cache subscription handler'); + } }); } } @@ -261,5 +284,10 @@ class Cache { } } +// Define constants on the prototype, but prevent a user of the cache from overwriting them for all +// instances +Object.defineProperty(Cache.prototype, 'CALLBACK_PREFIX', { value: 'callback_', writable: false }); +Object.defineProperty(Cache.prototype, 'REQUEST_PREFIX', { value: 'request_', writable: false }); +Object.defineProperty(Cache.prototype, 'EVENT_SET', { value: '__keyevent@0__:set', writable: false }); module.exports = Cache; diff --git a/src/lib/model/InboundTransfersModel.js b/src/lib/model/InboundTransfersModel.js index 81c4c81dc..79004f11a 100644 --- a/src/lib/model/InboundTransfersModel.js +++ b/src/lib/model/InboundTransfersModel.js @@ -22,6 +22,7 @@ const { } = require('@mojaloop/sdk-standard-components'); const shared = require('@internal/shared'); + /** * Models the operations required for performing inbound transfers */ @@ -33,6 +34,7 @@ class InboundTransfersModel { this._expirySeconds = config.expirySeconds; this._rejectTransfersOnExpiredQuotes = config.rejectTransfersOnExpiredQuotes; this._allowTransferWithoutQuote = config.allowTransferWithoutQuote; + this._reserveNotification = config.reserveNotification; this._mojaloopRequests = new MojaloopRequests({ logger: this._logger, @@ -40,6 +42,7 @@ class InboundTransfersModel { alsEndpoint: config.alsEndpoint, quotesEndpoint: config.quotesEndpoint, transfersEndpoint: config.transfersEndpoint, + bulkTransfersEndpoint: config.bulkTransfersEndpoint, dfspId: config.dfspId, tls: config.tls, jwsSign: config.jwsSign, @@ -292,7 +295,7 @@ class InboundTransfersModel { // create a mojaloop transfer fulfil response const mojaloopResponse = { completedTimestamp: new Date(), - transferState: 'COMMITTED', + transferState: this._reserveNotification ? 'RESERVED' : 'COMMITTED', fulfilment: fulfilment, ...response.extensionList && { extensionList: { @@ -584,9 +587,9 @@ class InboundTransfersModel { return { transferId: transfer.transferId, fulfilment: fulfilments[transfer.transferId], - ...response.individualTransferResults[transfer.transferId].extensionList && { + ...transfer.extensionList && { extensionList: { - extension: response.individualTransferResults[transfer.transferId].extensionList, + extension: transfer.extensionList, }, } }; @@ -594,7 +597,7 @@ class InboundTransfersModel { } // make a callback to the source fsp with the transfer fulfilment - return this._mojaloopRequests.putBulkTransfers(bulkPrepareRequest.transferId, mojaloopResponse, sourceFspId); + return this._mojaloopRequests.putBulkTransfers(bulkPrepareRequest.bulkTransferId, mojaloopResponse, sourceFspId); } catch (err) { this._logger.push({ err }).log('Error in prepareBulkTransfers'); @@ -666,23 +669,14 @@ class InboundTransfersModel { async _handleError(err) { let mojaloopErrorCode = Errors.MojaloopApiErrorCodes.INTERNAL_SERVER_ERROR; - if (err instanceof HTTPResponseError) { + + if(err instanceof HTTPResponseError) { const e = err.getData(); - if(e.res && (e.res.body || e.res.data)) { - if(e.res.body) { - try { - const bodyObj = JSON.parse(e.res.body); - mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${bodyObj.statusCode}`); - } catch(ex) { - // do nothing - this._logger.push({ ex }).log('Error parsing error message body as JSON'); - } - - } else if(e.res.data) { - mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${e.res.data.statusCode}`); - } + if(e.res && e.res.data) { + mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${e.res.data.statusCode}`); } } + return new Errors.MojaloopFSPIOPError(err, null, null, mojaloopErrorCode).toApiErrorObject(); } } diff --git a/src/lib/model/lib/requests/backendRequests.js b/src/lib/model/lib/requests/backendRequests.js index a5b4efd5e..fcec321cd 100644 --- a/src/lib/model/lib/requests/backendRequests.js +++ b/src/lib/model/lib/requests/backendRequests.js @@ -136,7 +136,7 @@ class BackendRequests { * @returns {object} - JSON response body if one was received */ async postBulkTransfers(prepare) { - return this._post('bulktransfers', prepare); + return this._post('bulkTransfers', prepare); } /** diff --git a/src/lib/model/lib/shared/index.js b/src/lib/model/lib/shared/index.js index 7a243eacb..939b1a86b 100644 --- a/src/lib/model/lib/shared/index.js +++ b/src/lib/model/lib/shared/index.js @@ -24,7 +24,7 @@ const internalPartyToMojaloopParty = (internal, fspId) => { partySubIdOrType: internal.idSubValue, fspId: fspId } - }; + }; if (internal.extensionList) { party.partyIdInfo.extensionList = { extension: internal.extensionList @@ -440,8 +440,8 @@ const mojaloopBulkPrepareToInternalBulkTransfer = (external, bulkQuotes, ilp) => bulkTransferId: external.bulkTransferId, individualTransfers: external.individualTransfers.map((transfer) => ({ transferId: transfer.transferId, - currency: transfer.amount.currency, - amount: transfer.amount.amount, + currency: transfer.transferAmount.currency, + amount: transfer.transferAmount.amount, })) }; } diff --git a/src/package.json b/src/package.json index 26c14de58..f1c67a61e 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@mojaloop/sdk-scheme-adapter", - "version": "10.6.2", + "version": "11.0.0", "description": "An adapter for connecting to Mojaloop API enabled switches.", "main": "index.js", "scripts": { @@ -40,7 +40,8 @@ "koa-body": "^4.1.1", "koa2-oauth-server": "^1.0.0", "node-fetch": "^2.6.0", - "uuidv4": "^6.0.8" + "uuidv4": "^6.0.8", + "ws": "^7.3.1" }, "devDependencies": { "@types/jest": "^25.2.1", diff --git a/src/test/unit/TestServer.test.js b/src/test/unit/TestServer.test.js index 72386df57..2994c340e 100644 --- a/src/test/unit/TestServer.test.js +++ b/src/test/unit/TestServer.test.js @@ -17,6 +17,7 @@ const putPartiesBody = require('./data/putPartiesBody'); const postQuotesBody = require('./data/postQuotesBody'); const putParticipantsBody = require('./data/putParticipantsBody'); const commonHttpHeaders = require('./data/commonHttpHeaders'); +const WebSocket = require('ws'); const cache = require('@internal/cache'); jest.mock('@internal/cache'); @@ -26,8 +27,18 @@ jest.mock('@internal/requests'); const InboundServer = require('../../InboundServer'); const TestServer = require('../../TestServer'); +const createWsClient = async (port, path) => { + const result = new WebSocket(`ws://127.0.0.1:${port}${path}`); + await new Promise((resolve, reject) => { + result.on('open', resolve); + result.on('error', reject); + }); + return result; +}; + describe('Test Server', () => { - let testServer, inboundServer, inboundReq, testReq, serverConfig, inboundCache, testCache; + let testServer, inboundServer, inboundReq, testReq, serverConfig, inboundCache, testCache, + wsClients; beforeEach(async () => { cache.mockClear(); @@ -38,8 +49,11 @@ describe('Test Server', () => { }; testServer = new TestServer(serverConfig); - testReq = supertest(await testServer.setupApi()); + await testServer.setupApi(); await testServer.start(); + + expect(testServer._server.listening).toBe(true); + testReq = supertest.agent(testServer._server); testCache = cache.mock.instances[0]; inboundServer = new InboundServer(serverConfig); @@ -47,10 +61,23 @@ describe('Test Server', () => { await inboundServer.start(); inboundCache = cache.mock.instances[1]; + wsClients = { + root: await createWsClient(serverConfig.testPort, '/'), + callbacks: await createWsClient(serverConfig.testPort, '/callbacks'), + requests: await createWsClient(serverConfig.testPort, '/requests'), + }; + + expect(Object.values(wsClients).every((cli) => cli.readyState === WebSocket.OPEN)).toBe(true); + expect(testServer._wsClients.size).toBeGreaterThan(0); + expect(cache).toHaveBeenCalledTimes(2); }); afterEach(async () => { + await Promise.all(Object.values(wsClients).map((cli) => { + cli.close(); + return new Promise((resolve) => cli.on('close', resolve)); + })); await testServer.stop(); await inboundServer.stop(); }); @@ -113,4 +140,251 @@ describe('Test Server', () => { expect(inboundCache.set.mock.calls[0][0]).toEqual(testCache.get.mock.calls[0][0]); }); + + test('Subscribes to the keyevent set notification', async () => { + expect(testServer._cache.subscribe).toBeCalledTimes(1); + expect(testServer._cache.subscribe).toHaveBeenCalledWith( + testServer._cache.EVENT_SET, + expect.any(Function), + ); + }); + + test('Configures cache correctly', async () => { + expect(testServer._cache.setTestMode).toBeCalledTimes(1); + expect(testServer._cache.setTestMode).toHaveBeenCalledWith(true); + }); + + test('WebSocket /callbacks and / endpoint triggers send to client when callback received to inbound server', async () => { + const participantId = '00000000-0000-1000-a000-000000000002'; + + const headers = { + ...commonHttpHeaders, + 'fspiop-http-method': 'PUT', + 'fspiop-uri': `/participants/${participantId}`, + 'date': new Date().toISOString(), + }; + + const putParticipantWsClient = await createWsClient( + serverConfig.testPort, + `/callbacks/${participantId}` + ); + + const putParticipantEndpointMessageReceived = new Promise(resolve => { + putParticipantWsClient.on('message', resolve); + }); + const serverCallbackEndpointMessageReceived = new Promise(resolve => { + wsClients.callbacks.on('message', resolve); + }); + const serverRootEndpointMessageReceived = new Promise(resolve => { + wsClients.root.on('message', resolve); + }); + + // get the callback function that the test server subscribed with, and mock the cache by + // calling the callback when the inbound server sets a key in the cache. + const callback = testServer._cache.subscribe.mock.calls[0][1]; + inboundServer._cache.set = jest.fn(async (key) => await callback( + inboundServer._cache.EVENT_SET, + key, + 1, + )); + testServer._cache.get = jest.fn(() => ({ + data: putParticipantsBody, + headers, + })); + + await inboundReq + .put(`/participants/${participantId}`) + .send(putParticipantsBody) + .set(headers); + + expect(inboundServer._cache.set).toHaveBeenCalledTimes(1); + expect(inboundServer._cache.set).toHaveBeenCalledWith( + `${testServer._cache.CALLBACK_PREFIX}${participantId}`, + { + data: putParticipantsBody, + headers: expect.objectContaining(headers), + } + ); + + expect(testServer._cache.get).toHaveBeenCalledTimes(1); + expect(testServer._cache.get).toHaveBeenCalledWith( + `${testServer._cache.CALLBACK_PREFIX}${participantId}` + ); + + const expectedMessage = { + data: putParticipantsBody, + headers: expect.objectContaining(headers), + id: participantId + }; + + // Expect the client websockets to receive a message containing the callback headers and + // body + const callbackClientResult = JSON.parse(await serverCallbackEndpointMessageReceived); + expect(callbackClientResult).toEqual(expectedMessage); + const rootClientResult = JSON.parse(await serverRootEndpointMessageReceived); + expect(rootClientResult).toEqual(expectedMessage); + const putParticipantClientClientResult = JSON.parse(await putParticipantEndpointMessageReceived); + expect(putParticipantClientClientResult).toEqual(expectedMessage); + }); + + test('WebSocket /requests and / endpoint triggers send to client when callback received to inbound server', async () => { + const headers = { + ...commonHttpHeaders, + 'fspiop-http-method': 'POST', + 'fspiop-uri': '/quotes', + 'date': new Date().toISOString(), + }; + + const postQuoteWsClient = await createWsClient( + serverConfig.testPort, + `/requests/${postQuotesBody.quoteId}` + ); + + const postQuoteEndpointMessageReceived = new Promise(resolve => { + postQuoteWsClient.on('message', resolve); + }); + const serverRequestEndpointMessageReceived = new Promise(resolve => { + wsClients.requests.on('message', resolve); + }); + const serverRootEndpointMessageReceived = new Promise(resolve => { + wsClients.root.on('message', resolve); + }); + + // get the callback function that the test server subscribed with, and mock the cache by + // calling the callback when the inbound server sets a key in the cache. + const callback = testServer._cache.subscribe.mock.calls[0][1]; + inboundServer._cache.set = jest.fn(async (key) => await callback( + inboundServer._cache.EVENT_SET, + key, + 1, + )); + testServer._cache.get = jest.fn(() => ({ + data: postQuotesBody, + headers, + })); + + await inboundReq + .post('/quotes') + .send(postQuotesBody) + .set(headers); + + // Called once for the quote request, once for the fulfilment + expect(inboundServer._cache.set).toHaveBeenCalledTimes(2); + expect(inboundServer._cache.set).toHaveBeenCalledWith( + `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}`, + { + data: postQuotesBody, + headers: expect.objectContaining(headers), + } + ); + + expect(testServer._cache.get).toHaveBeenCalledTimes(1); + expect(testServer._cache.get).toHaveBeenCalledWith( + `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}` + ); + + const expectedMessage = { + data: postQuotesBody, + headers: expect.objectContaining(headers), + id: postQuotesBody.quoteId, + }; + + // Expect the client websockets to receive a message containing the callback headers and + // body + const callbackClientResult = JSON.parse(await serverRequestEndpointMessageReceived); + expect(callbackClientResult).toEqual(expectedMessage); + const rootClientResult = JSON.parse(await serverRootEndpointMessageReceived); + expect(rootClientResult).toEqual(expectedMessage); + const postQuoteClientResult = JSON.parse(await postQuoteEndpointMessageReceived); + expect(postQuoteClientResult).toEqual(expectedMessage); + }); + + test('Websocket / endpoint receives both callbacks and requests', async () => { + const quoteRequestHeaders = { + ...commonHttpHeaders, + 'fspiop-http-method': 'POST', + 'fspiop-uri': '/quotes', + 'date': new Date().toISOString(), + }; + + const serverRootEndpointMessageReceived = new Promise(resolve => { + wsClients.root.on('message', resolve); + }); + + // get the callback function that the test server subscribed with, and mock the cache by + // calling the callback when the inbound server sets a key in the cache. + const callback = testServer._cache.subscribe.mock.calls[0][1]; + inboundServer._cache.set = jest.fn(async (key) => await callback( + inboundServer._cache.EVENT_SET, + key, + 1, + )); + testServer._cache.get = jest.fn(() => ({ + data: postQuotesBody, + headers: quoteRequestHeaders, + })); + + await inboundReq + .post('/quotes') + .send(postQuotesBody) + .set(quoteRequestHeaders); + + // Called once for the quote request, once for the fulfilment + expect(inboundServer._cache.set).toHaveBeenCalledTimes(2); + expect(inboundServer._cache.set).toHaveBeenCalledWith( + `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}`, + { + data: postQuotesBody, + headers: expect.objectContaining(quoteRequestHeaders), + } + ); + + expect(testServer._cache.get).toHaveBeenCalledTimes(1); + expect(testServer._cache.get).toHaveBeenCalledWith( + `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}` + ); + + const expectedMessage = { + data: postQuotesBody, + headers: expect.objectContaining(quoteRequestHeaders), + id: postQuotesBody.quoteId, + }; + + // Expect the client websockets to receive a message containing the callback + // quoteRequestHeaders and body + const rootEndpointResult = JSON.parse(await serverRootEndpointMessageReceived); + expect(rootEndpointResult).toEqual(expectedMessage); + + const participantId = '00000000-0000-1000-a000-000000000002'; + + const putParticipantsHeaders = { + ...commonHttpHeaders, + 'fspiop-http-method': 'PUT', + 'fspiop-uri': `/participants/${participantId}`, + 'date': new Date().toISOString(), + }; + + await inboundReq + .put(`/participants/${participantId}`) + .send(putParticipantsBody) + .set(putParticipantsHeaders); + + // Called twice for the quote request earlier in this test, another time now for the put + // participants request + expect(inboundServer._cache.set).toHaveBeenCalledTimes(3); + expect(inboundServer._cache.set.mock.calls[2]).toEqual([ + `${testServer._cache.CALLBACK_PREFIX}${participantId}`, + { + data: putParticipantsBody, + headers: expect.objectContaining(putParticipantsHeaders), + } + ]); + + // Called once for the quote request earlier in this test, another time now for the + // participants callback + expect(testServer._cache.get).toHaveBeenCalledTimes(2); + expect(testServer._cache.get.mock.calls[1]).toEqual([ + `${testServer._cache.CALLBACK_PREFIX}${participantId}` + ]); + }); }); diff --git a/src/test/unit/data/defaultConfig.json b/src/test/unit/data/defaultConfig.json index 96c674de3..96f4e7f66 100644 --- a/src/test/unit/data/defaultConfig.json +++ b/src/test/unit/data/defaultConfig.json @@ -1,6 +1,7 @@ { "inboundPort": 4000, "outboundPort": 4001, + "testPort": 4002, "peerEndpoint": "172.17.0.2:3001", "backendEndpoint": "172.17.0.2:3001", "alsEndpoint": "127.0.0.1:6500", diff --git a/src/test/unit/lib/model/InboundTransfersModel.test.js b/src/test/unit/lib/model/InboundTransfersModel.test.js index 34f61580c..0d4110a48 100644 --- a/src/test/unit/lib/model/InboundTransfersModel.test.js +++ b/src/test/unit/lib/model/InboundTransfersModel.test.js @@ -339,9 +339,9 @@ describe('inboundModel', () => { BackendRequests.__getTransfers = jest.fn().mockReturnValue( Promise.reject(new HTTPResponseError({ res: { - body: JSON.stringify({ + data: { statusCode: '3208' - }), + }, } }))); @@ -512,9 +512,9 @@ describe('inboundModel', () => { BackendRequests.__getBulkTransfers = jest.fn().mockReturnValue( Promise.reject(new HTTPResponseError({ res: { - body: JSON.stringify({ + data: { statusCode: '3208' - }), + }, } }))); @@ -570,7 +570,7 @@ describe('inboundModel', () => { individualTransfers: [ { transferId: 'fake-transfer-id', - amount: { + transferAmount: { currency: 'USD', amount: 20.13 }, diff --git a/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js index 543d03deb..0a005abcc 100644 --- a/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js +++ b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js @@ -252,6 +252,7 @@ describe('thirdpartyTransactionModel', () => { it('should implement happy flow', async () => { data.transactionRequestId = uuid(); + data.currentState = 'postTransaction'; let payerInformation = { 'personalInfo': { 'complexName': { @@ -289,13 +290,12 @@ describe('thirdpartyTransactionModel', () => { expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); // check invocation of request.postThirdpartyRequestsTransactions - expect(ThirdpartyRequests.__postThirdpartyRequestsTransactions).toBeCalledWith(data.transactionRequestId, 'dfspa'); + expect(ThirdpartyRequests.__postThirdpartyRequestsTransactions).toBeCalledWith(data, 'dfspa'); // check that this.context.data is updated expect(model.context.data).toEqual({ - Iam: 'the-body', - transactionRequestId: model.context.data.transactionRequestId, - payer: payerInformation, + 'transactionId': '5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5', + 'transactionRequestState': 'RECEIVED', // current state will be updated by onAfterTransition which isn't called // when manual invocation of transition handler happens currentState: 'postTransaction' diff --git a/src/test/unit/outboundApi/handlers.test.js b/src/test/unit/outboundApi/handlers.test.js index b4aa97a7d..f748224fb 100644 --- a/src/test/unit/outboundApi/handlers.test.js +++ b/src/test/unit/outboundApi/handlers.test.js @@ -148,7 +148,7 @@ describe('Outbound API handlers:', () => { }; await handlers['/transfers'].post(mockContext); - + // check response is correct expect(mockContext.response.status).toEqual(500); expect(mockContext.response.body).toBeTruthy(); @@ -203,7 +203,7 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: console, + logger: mockLogger({ app: 'outbound-api-handlers-test'}), path: { params: { transferId: '12345' @@ -240,7 +240,7 @@ describe('Outbound API handlers:', () => { }; await handlers['/bulkTransfers'].post(mockContext); - + // check response is correct expect(mockContext.response.status).toEqual(500); expect(mockContext.response.body).toBeTruthy(); @@ -263,7 +263,7 @@ describe('Outbound API handlers:', () => { conf: { outboundErrorStatusCodeExtensionKey: 'extErrorKey' // <- tell the handler to use this extensionList item as source of statusCode }, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; @@ -293,12 +293,12 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; await handlers['/bulkQuotes'].post(mockContext); - + // check response is correct expect(mockContext.response.status).toEqual(500); expect(mockContext.response.body).toBeTruthy(); @@ -321,7 +321,7 @@ describe('Outbound API handlers:', () => { conf: { outboundErrorStatusCodeExtensionKey: 'extErrorKey' // <- tell the handler to use this extensionList item as source of statusCode }, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; @@ -351,7 +351,7 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - logger: console + logger: mockLogger({ app: 'outbound-api-handlers-test'}) } }; @@ -395,7 +395,7 @@ describe('Outbound API handlers:', () => { describe('POST /authorizations', () => { test('happy flow', async() => { - + const mockContext = { request: { body: {the: 'body', toParticipantId: 'pisp', transactionRequestId: '123'}, @@ -411,12 +411,12 @@ describe('Outbound API handlers:', () => { cache: { the: 'mocked cache' } }, }; - + // mock state machine const mockedPSM = { run: jest.fn(async () => ({ the: 'run response' })) }; - + const createSpy = jest.spyOn(OutboundAuthorizationsModel, 'create') .mockImplementationOnce(async () => mockedPSM); @@ -447,7 +447,7 @@ describe('Outbound API handlers:', () => { describe('GET /parties/{Type}/{ID}/{SubId}', () => { test('happy flow', async() => { - + const mockContext = { request: {}, response: {}, @@ -465,12 +465,12 @@ describe('Outbound API handlers:', () => { }, }, }; - + // mock state machine const mockedPSM = { run: jest.fn(async () => ({ the: 'run response' })) }; - + const createSpy = jest.spyOn(PartiesModel, 'create') .mockImplementationOnce(async () => mockedPSM); @@ -495,7 +495,7 @@ describe('Outbound API handlers:', () => { expect(mockContext.response.body).toEqual({ the: 'run response' }); }); - test('error flow', async() => { + test('error flow', async() => { const mockContext = { request: {}, response: {}, @@ -513,12 +513,12 @@ describe('Outbound API handlers:', () => { }, }, }; - + // mock state machine const mockedPSM = { run: jest.fn(async () => ({ errorInformation: { Iam: 'the-error'} })) }; - + const createSpy = jest.spyOn(PartiesModel, 'create') .mockImplementationOnce(async () => mockedPSM); @@ -545,7 +545,7 @@ describe('Outbound API handlers:', () => { }); describe('GET /parties/{Type}/{ID}', () => { test('happy flow', async() => { - + const mockContext = { request: {}, response: {}, @@ -562,12 +562,12 @@ describe('Outbound API handlers:', () => { }, }, }; - + // mock state machine const mockedPSM = { run: jest.fn(async () => ({ the: 'run response' })) }; - + const createSpy = jest.spyOn(PartiesModel, 'create') .mockImplementationOnce(async () => mockedPSM); @@ -592,7 +592,7 @@ describe('Outbound API handlers:', () => { expect(mockContext.response.body).toEqual({ the: 'run response' }); }); - test('error flow', async() => { + test('error flow', async() => { const mockContext = { request: {}, response: {}, @@ -609,12 +609,12 @@ describe('Outbound API handlers:', () => { }, }, }; - + // mock state machine const mockedPSM = { run: jest.fn(async () => ({ errorInformation: { Iam: 'the-error'} })) }; - + const createSpy = jest.spyOn(PartiesModel, 'create') .mockImplementationOnce(async () => mockedPSM); @@ -638,5 +638,5 @@ describe('Outbound API handlers:', () => { expect(mockContext.response.status).toBe(404); expect(mockContext.response.body).toEqual({ errorInformation: { Iam: 'the-error'} }); }); - }); + }); }); From 8d1baab99e2f13c518b6fee6dca33f02e4b899de Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Mon, 4 Jan 2021 06:00:11 -0500 Subject: [PATCH 26/29] chore: sync pisp/master to v11.10.1 (#237) * Renamed ALS_ENDPOINT_HOST to ALS_ENDPOINT * Bumped up the version * #1325: Bulk quotes and bulk transfers support (#159) * Initial commit for bulk transfers support * Initial commit for bulk quotes and bulkt transfers support * Complete inbound API bulk quotes implementation * Separate bulk quotes switch base URL * Add postTransfers and getBulkTransfers * Update Outbound API spec with bulk transfers endpoints * Update outbound API spec * Add outbound bulk transfers * Fix API spec bugs * Add bulk party lookup * Whitespace fixes * Add outbound bulk quotes requests * Remove used imports * Add outbound POST bulk transfers handler * Add outboud GET /bulkTransfers/{ID} * Add Jest config for test debugging * Add unit tests for bulk quotes and bulk transfers * Add tests for outbound API. Fix missing specs entries. * Add tests for outbound API /bulkQuotes abd /bulkTransfers * Add inbound GET /bulkQuotes/{ID} * Add unit tests for inbound GET bulkQuotesById, PUT bulkQuotesById, PUT bulkQuotesErrorById * Fix spec description for /bulkTransfers and variable initialization in InboundTransfersModel * Remove unreferenced spec entry. * Updates per code review * Updates per code review * Updates per code review * Add more unit tests for InboundTransfersModel. Mor code review changes * Remodel bulk quotes using single state state-machine pattern * Remodel bulk transfers using state machine pattern. Add GET bulk quotes endpoint * Update API spec * Fix tests * Add more unit tests for InboundTransfersModel * Add unit tests for OutboundBulkQuotesModel * Add tests for OutboundBulkTransfersModel * Fix linting issue * PR review updates. * Remove some IDE auto inserted whitespaces * PR review update Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> * upstream merge * Fix misspelt environment variable * Bug fixes with golden path * Update unit test * Add bulk transfers and bulk quotes to the list of valid headers * added functionality for reserve notificaiton by payee * added enum for support of transfer reserve notification * removed local certs and keys * updated version * requests now respond with .data instead of .body (#182) * requests now respond with .data instead of .body * Fixed test. Fixed (not in the broken sense, but sort of) package version. * fixed lint errors * corrected version * Related to #182, no longer need to parse response data * Updated test * WIP * Basically functions. Needs a little tidy-up. * Made Cache constants read-only on the prototype. Set Redis config when in test mode. Tidied up websocket server creation. Removed koa-websocket and added ws. * Rearranged to make diff easier * Linting * Update sdk-standard-component * Now starting and stopping actual test server in unit tests to enable websocket tests. Added a little logging of client information to websocket connections and disconnections. Waiting for all websocket clients to close before concluding test server shutdown. Wrote tests for test server websockets. * Linting * Added support for specific callbacks and requests * Modified websocket endpoint names to match http endpoint names to streamline migration. Now printing entire x-forwarded-for header as client IP address list per review feedback. * Use process.exit instead of process.exitCode * Use process.exit instead of process.exitCode * Send the message ID to the client (#189) * Update FSPIOP API version * Update src/InboundServer/api.yaml Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> * changed inboud api * Delete launch.json * removed package-lock files * removed more package-locks * added missing header in validateHeaders * changed " to ' on refs * fixed missing # in refs in api.yaml * Disable test-integration job due to security flaw, re-enable image scan, fix flaky pipeline, and remove trailing whitespace (#193) * Re-enable image scan * Fix indentation * Fix indentation * Revert to npm install and package.json * Add vulnerability check and change pipeline order * Fix YAML * Add audit resolver and switch to package-lock.json * Update license-scan for local image * Update node * Update image-scan * Add script * Downgrade npm-audit-resolver * Attempt Dockerfile fix * Bump cache dependency version * Disable test-integration pending security fix * Remove unused script * Bump patch version for built container change * Add timestamp information to transfer. (#195) * Add timestamp information to transfer. * Fix test cases. * [WIP] added PUT /transfers/transferId handling for notifications to payee (#194) * added PUT /transfers/transferId handling for notifications to payee * changed the error handling logic on sendNotification functionality * added unit tests * added unit tests * added contributors and bumped up version * fixed log added comments removed retry configs Co-authored-by: Valentin * Enable test-integration job in pipeline (#197) * mege * Add ISO-4217 test currencies to enable production testing scenarios. (#198) * Add ISO-4217 test currencies to enable production testing scenarios. * bump package version * added some comments, fixed typo and version * updated package-lock * rolling back integration.env * rollingback integration.env take 2 * rollback integration.env final * fix .env.example * package-lock fix * fixing linter errors * moved resources_versions mapping into config.js and added unit tests and validation * fixed variables missnaming * removed white spaces * negative test; function rename and change of the return of parse function * fixed linting errors * removed package-lock * made src/package-lock again * Use @mojaloop/sdk-standard-components v11.5.2 (#200) Bumped to 11.2.1 * Bump version * Remove wso2auth from Test Server * Removed vulnerable and unused node-fetch * Bumped version * Try out with direct swagger update * MP-2317: update sdk-standard-components to v11.5.3 * Fix broken tests * Add test for 'addCustomKeys' * Refactor test * Fix unit tests * Minor refactor * Add tests for accented characters validation * Fix lint * Remove unused code * Bump version * Fix version * Added TRANSACTION_REQUESTS_ENDPOINT config option * Bumped up the version * merge with master * merge on * fixed bulk * fixed lint errors * jws key restored * reverted accent character commit * recover * reverted commit * removed openapi * removed openapi tests * fixed failing tests * fixed lint errors * Reorganise internal server API (#207) * Change to external log from sdk-standard-components * Removed some testing-specific configuration * Separated sync and async functionality a bit more clearly * Separate functionality into that which can be reconfigured and that which cannot. Enable reconfiguration. * Lint * Fixed Dockerfile * Separated InboundApi (private) and InboundServer (public) classes * Fixed integration test Dockerfile * Made cache connection/disconnection reentrant * Split logger out of inbound server. Added "check" internal package. * Using npm ci in Dockerfiles * Moved cache creation out of individual servers. Separated TestServer into TestServer, WsServer and TestApi classes. Separated OutboundServer into OutboundServer and OutboundApi classes. * Extracted logger creation from OAuthTestServer * Removed state from logger middleware. Deduped some middleware. * Added new internal package to Dockerfiles * Lint * Partial config restructure for clearer API (#208) * Specify more precise config options to TestServer * Bumped package version * Factored caching into middleware * Began restructuring config per-server * Linting. Updated dependency. * Hoist logging config through levels * Lint * Extract cache connection logic from servers * Removed vestiges of unnecessary functionality * Lint * Removed TLS from TestServer (#213) * Removed TLS from TestServer * Lint * Mp 2375 (#211) * Add ISO-4217 test currencies to enable production testing scenarios. * dont forget to save the model in the cache even if outbound transfer fails. It will be needed for debugging. Co-authored-by: Matt Kingston * Do not start WSO2 auth unnecessarily * save transfer state if error is thrown in run method (#215) Co-authored-by: Matt Kingston * Some minor fixes * Silenced tests * Use logger instead of console * Lint * Refactored OAuth test server constructor * bugfix * bugfix * Incorporated new sdk-standard-components functionality into SDK * Bumped package version * Run all tests * Log request retries * Bumped package version * Remove redundant example env vars * fixed config refactoring issue * fixedt typo * Added GET /quotes/{ID} to the inbound API (#228) * Added GET /quotes{ID} * added the error callback * update readme * Added PUT /participants/{Type}/{ID}/{SubId} and PUT /participants/{Type}/{ID}/{SubId}/error (#233) * Added GET /quotes{ID} * added PUT /participants * merge master * delete accidentally added files * chore: update package-lock * chore: fix bad merge code block * chore: fix linting issues * chore: fix faulty test Co-authored-by: Vijay Kumar Co-authored-by: Yevhen Co-authored-by: Steven Oderayi Co-authored-by: Sam <10507686+elnyry-sam-k@users.noreply.github.com> Co-authored-by: Valentin Co-authored-by: Rajiv Mothilal Co-authored-by: Matt Kingston Co-authored-by: Valentin Genev Co-authored-by: Kamuela Franco Co-authored-by: James Bush <37296643+bushjames@users.noreply.github.com> Co-authored-by: Vassilis Barzokas Co-authored-by: Murthy Kakarlamudi Co-authored-by: vijayg10 <33152110+vijayg10@users.noreply.github.com> Co-authored-by: shashi165 <33355509+shashi165@users.noreply.github.com> --- Dockerfile | 10 +- Dockerfile-integration | 44 + README.md | 16 +- docker-compose.integration.yml | 1 + docs/dfspInboundApi.yaml | 43 +- src/.env.example | 21 +- src/.eslintrc.json | 2 +- src/.npmrc | 1 - src/InboundServer/api.yaml | 3 +- src/InboundServer/handlers.js | 437 +- src/InboundServer/index.js | 253 +- src/InboundServer/middlewares.js | 145 +- src/OAuthTestServer/index.js | 56 +- src/OAuthTestServer/model.js | 8 +- src/OutboundServer/api.yaml | 28 +- src/OutboundServer/handlers.js | 31 +- src/OutboundServer/index.js | 147 +- src/OutboundServer/middlewares.js | 46 +- src/TestServer/index.js | 198 +- src/config.js | 75 +- src/index.js | 125 +- src/lib/cache/cache.js | 61 +- src/lib/check/index.js | 25 + src/lib/check/package.json | 12 + src/lib/log/log.js | 216 - src/lib/log/package.json | 20 - src/lib/log/transports.js | 61 - src/lib/model/AccountsModel.js | 2 +- .../InboundThirdpartyTransactionModel.js | 10 +- src/lib/model/InboundTransfersModel.js | 59 +- src/lib/model/OutboundAuthorizationsModel.js | 37 +- src/lib/model/OutboundBulkQuotesModel.js | 4 +- src/lib/model/OutboundBulkTransfersModel.js | 9 +- src/lib/model/OutboundRequestToPayModel.js | 6 +- .../OutboundRequestToPayTransferModel.js | 5 +- .../OutboundThirdpartyTransactionModel.js | 7 +- src/lib/model/OutboundTransfersModel.js | 14 +- src/lib/model/PartiesModel.js | 25 +- src/lib/model/lib/requests/backendRequests.js | 13 +- src/lib/model/lib/shared/index.js | 2 +- src/lib/randomphrase/randomphrase.js | 3 +- src/lib/validate/index.js | 2 +- src/package-lock.json | 8472 +++++++++++++++++ src/package.json | 32 +- src/test/__mocks__/@internal/requests.js | 3 + .../@mojaloop/sdk-standard-components.js | 23 +- src/test/config/integration.env | 4 +- src/test/integration/lib/cache.test.js | 12 +- src/test/unit/InboundServer.test.js | 38 +- src/test/unit/TestServer.test.js | 137 +- src/test/unit/api/accounts/utils.js | 1 - src/test/unit/api/transfers/utils.js | 19 +- src/test/unit/api/utils.js | 16 +- src/test/unit/config.test.js | 27 +- src/test/unit/data/defaultConfig.json | 50 +- src/test/unit/inboundApi/handlers.test.js | 40 + src/test/unit/index.test.js | 23 +- src/test/unit/lib/cache/cache.test.js | 11 +- src/test/unit/lib/log/log.test.js | 76 - src/test/unit/lib/model/AccountsModel.test.js | 10 +- .../lib/model/InboundTransfersModel.test.js | 55 +- .../lib/model/OutboundBulkQuotesModel.test.js | 18 +- .../model/OutboundBulkTransfersModel.test.js | 18 +- .../model/OutboundRequestToPayModel.test.js | 28 +- .../OutboundRequestToPayTransferModel.test.js | 26 +- ...OutboundThirdpartyTransactionModel.test.js | 15 + .../lib/model/OutboundTransfersModel.test.js | 31 +- src/test/unit/lib/model/PartiesModel.test.js | 44 +- .../common/PersistentStateMachine.test.js | 26 +- .../unit/lib/model/data/defaultConfig.json | 48 +- .../unit/lib/model/data/mockArguments.json | 2 +- .../lib/model/data/notificationToPayee.json | 10 + src/test/unit/mockLogger.js | 7 +- src/test/unit/outboundApi/handlers.test.js | 5 - 74 files changed, 9999 insertions(+), 1611 deletions(-) create mode 100644 Dockerfile-integration delete mode 100644 src/.npmrc create mode 100644 src/lib/check/index.js create mode 100644 src/lib/check/package.json delete mode 100644 src/lib/log/log.js delete mode 100644 src/lib/log/package.json delete mode 100644 src/lib/log/transports.js create mode 100644 src/package-lock.json delete mode 100644 src/test/unit/lib/log/log.test.js create mode 100644 src/test/unit/lib/model/data/notificationToPayee.json diff --git a/Dockerfile b/Dockerfile index 91427653a..081cff1e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.16.1-alpine as builder +FROM node:12.18.3-alpine as builder RUN apk add --no-cache git python build-base @@ -10,17 +10,18 @@ WORKDIR /src/ # files change- only when any dependencies change- which is a superior developer experience when # relying on docker-compose. COPY ./src/package.json ./package.json +COPY ./src/package-lock.json ./package-lock.json COPY ./src/lib/cache/package.json ./lib/cache/package.json -COPY ./src/lib/log/package.json ./lib/log/package.json +COPY ./src/lib/check/package.json ./lib/check/package.json COPY ./src/lib/model/lib/requests/package.json ./lib/model/lib/requests/package.json COPY ./src/lib/model/lib/shared/package.json ./lib/model/lib/shared/package.json COPY ./src/lib/model/package.json ./lib/model/package.json COPY ./src/lib/randomphrase/package.json ./lib/randomphrase/package.json COPY ./src/lib/router/package.json ./lib/router/package.json COPY ./src/lib/validate/package.json ./lib/validate/package.json -RUN npm install +RUN npm ci --only=production -FROM node:12.16.1-alpine +FROM node:12.18.3-alpine ARG BUILD_DATE ARG VCS_URL @@ -37,7 +38,6 @@ LABEL org.label-schema.url="https://mojaloop.io/" LABEL org.label-schema.version=$VERSION COPY --from=builder /src/ /src -RUN npm prune --production COPY ./src ./src COPY ./secrets / diff --git a/Dockerfile-integration b/Dockerfile-integration new file mode 100644 index 000000000..cae1662a0 --- /dev/null +++ b/Dockerfile-integration @@ -0,0 +1,44 @@ +FROM node:12.18.3-alpine as builder + +RUN apk add --no-cache git python build-base + +EXPOSE 3000 + +WORKDIR /src/ + +# This is super-ugly, but it means we don't have to re-run npm install every time any of the source +# files change- only when any dependencies change- which is a superior developer experience when +# relying on docker-compose. +COPY ./src/package.json ./package.json +COPY ./src/package-lock.json ./package-lock.json +COPY ./src/lib/cache/package.json ./lib/cache/package.json +COPY ./src/lib/check/package.json ./lib/check/package.json +COPY ./src/lib/model/lib/requests/package.json ./lib/model/lib/requests/package.json +COPY ./src/lib/model/lib/shared/package.json ./lib/model/lib/shared/package.json +COPY ./src/lib/model/package.json ./lib/model/package.json +COPY ./src/lib/randomphrase/package.json ./lib/randomphrase/package.json +COPY ./src/lib/router/package.json ./lib/router/package.json +COPY ./src/lib/validate/package.json ./lib/validate/package.json +RUN npm ci + +FROM node:12.18.3-alpine + +ARG BUILD_DATE +ARG VCS_URL +ARG VCS_REF +ARG VERSION + +# See http://label-schema.org/rc1/ for label schema info +LABEL org.label-schema.schema-version="1.0" +LABEL org.label-schema.name="sdk-scheme-adapter" +LABEL org.label-schema.build-date=$BUILD_DATE +LABEL org.label-schema.vcs-url=$VCS_URL +LABEL org.label-schema.vcs-ref=$VCS_REF +LABEL org.label-schema.url="https://mojaloop.io/" +LABEL org.label-schema.version=$VERSION + +COPY --from=builder /src/ /src +COPY ./src ./src +COPY ./secrets / + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 74d9e6447..a58b8ee56 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ # Mojaloop SDK Scheme Adapter - This package provides a scheme adapter that interfaces between a Mojaloop API compliant switch and a DFSP backend platform that does not natively implement the Mojaloop API. The API between the scheme adapter and the DFSP backend is synchronous HTTP while the interface between the scheme adapter and the switch is native Mojaloop API. @@ -155,3 +154,18 @@ docker exec -it scheme-adapter-int sh -c 'npm run test:int' # copy results out docker cp scheme-adapter-int:/src/junit.xml . ``` + +### Get status of quote request +The status of a previously sent quotation request can be get by executing `GET /quotes/{ID}`. +When the response to the original quote request is sent, the response is cached in the redis store. When a `GET /quotes/{ID}` is received, +the cached response is retrieved from the redis store and returned to the caller as a body with `PUT /quotes/{ID}` request. +When the redis is setup as a persistent store then it will return the response for all the quote requests sent with `POST /quotes`. If the the redis is setup as cache with expiry time then it will not return response for the expired quotes when the cache expires. Also only the payer dfsp is supposed to make the `GET /quotes/{ID}` request. +If the quote response is not found in the redis store `PUT /quotes/{ID}` will be made with the following body +``` +{ + "errorInformation":{ + "errorCode":"3205", + "errorDescription":"Quote ID not found" + } +} +``` diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index a2d9ad359..68c3ef327 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -11,6 +11,7 @@ services: container_name: scheme-adapter-int build: context: . + dockerfile: Dockerfile-integration target: builder env_file: ./src/test/config/integration.env volumes: diff --git a/docs/dfspInboundApi.yaml b/docs/dfspInboundApi.yaml index ac840b6b9..ad3e95fc4 100644 --- a/docs/dfspInboundApi.yaml +++ b/docs/dfspInboundApi.yaml @@ -9,7 +9,7 @@ info: license: name: Apache License Version 2.0, January 2004 url: http://www.apache.org/licenses/ - version: 1.0.0 + version: 1.1.0 paths: /participants/{idType}/{idValue}: @@ -171,6 +171,24 @@ paths: $ref: '#/components/schemas/transferDetailsResponse' 500: $ref: '#/components/responses/500' + put: + summary: Receive notification for a specific transfer + description: The HTTP request `PUT /transfers/{transferId}` is used to receive notification for transfer being fulfiled when the FSP is a Payee + tags: + - Transfers + parameters: + - $ref: '#/components/schemas/transferId' + requestBody: + description: An incoming notification for fulfiled transfer + content: + application/json: + schema: + $ref: '#/components/schemas/fulfilNotification' + responses: + 200: + description: The notification was accepted + 500: + $ref: '#/components/responses/500' components: schemas: @@ -345,6 +363,8 @@ components: - XDR - XOF - XPF + - XTS + - XXX - YER - ZAR - ZMW @@ -800,7 +820,6 @@ components: extensions: $ref: '#/components/schemas/extensionList' - transferStatus: type: string enum: @@ -808,6 +827,26 @@ components: - WAITING_FOR_QUOTE_ACCEPTANCE - COMPLETED + fulfilNotification: + title: TransfersIDPatchResponse + type: object + description: PUT /transfers/{transferId} object + properties: + completedTimestamp: + $ref: '#/components/schemas/timestamp' + description: Time and date when the transaction was completed. + example: "2020-05-19T08:38:08.699-04:00" + transferState: + $ref: '#/components/schemas/transferState' + description: State of the transfer. + example: COMMITTED + extensionList: + $ref: '#/components/schemas/ExtensionList' + description: Optional extension, specific to deployment. + required: + - completedTimestamp + - transferState + responses: '400': description: Malformed or missing required headers or parameters diff --git a/src/.env.example b/src/.env.example index 415eae8e4..7eb357797 100644 --- a/src/.env.example +++ b/src/.env.example @@ -11,7 +11,6 @@ TEST_LISTEN_PORT=4002 # environment, i.e. when you're running it locally against your own implementation. INBOUND_MUTUAL_TLS_ENABLED=false OUTBOUND_MUTUAL_TLS_ENABLED=false -TEST_MUTUAL_TLS_ENABLED=false # Enable verification or incoming JWS signatures # Note that signatures will be required on incoming messages @@ -34,6 +33,7 @@ JWS_SIGNING_KEY_PATH=/jwsSigningKey.key JWS_VERIFICATION_KEYS_DIRECTORY=/jwsVerificationKeys # Location of certs and key required for TLS + IN_CA_CERT_PATH=./secrets/cacert.pem IN_SERVER_CERT_PATH=./secrets/servercert.pem IN_SERVER_KEY_PATH=./secrets/serverkey.pem @@ -42,10 +42,6 @@ OUT_CA_CERT_PATH=./secrets/cacert.pem OUT_CLIENT_CERT_PATH=./secrets/servercert.pem OUT_CLIENT_KEY_PATH=./secrets/serverkey.pem -TEST_CA_CERT_PATH=./secrets/cacert.pem -TEST_CLIENT_CERT_PATH=./secrets/servercert.pem -TEST_CLIENT_KEY_PATH=./secrets/serverkey.pem - # The number of space characters by which to indent pretty-printed logs. If set to zero, log events # will each be printed on a single line. LOG_INDENT=0 @@ -55,14 +51,15 @@ CACHE_HOST=172.17.0.2 CACHE_PORT=6379 # SWITCH ENDPOINT -# The option 'PEER_ENDPOINT' has no effect if the remaining five options 'ALS_ENDPOINT', 'QUOTES_ENDPOINT', -# 'BULK_QUOTES_ENDPOINT', 'TRANSFERS_ENDPOINT', 'BULK_TRANSFERS_ENDPOINT' are specified. +# The option 'PEER_ENDPOINT' has no effect if the remaining options 'ALS_ENDPOINT', 'QUOTES_ENDPOINT', +# 'BULK_QUOTES_ENDPOINT', 'TRANSFERS_ENDPOINT', 'BULK_TRANSFERS_ENDPOINT', 'TRANSACTION_REQUESTS_ENDPOINT' are specified. PEER_ENDPOINT=172.17.0.3:4000 #ALS_ENDPOINT=account-lookup-service.local #QUOTES_ENDPOINT=quoting-service.local #BULK_QUOTES_ENDPOINT=quoting-service.local #TRANSFERS_ENDPOINT=ml-api-adapter.local #BULK_TRANSFERS_ENDPOINT=bulk-api-adapter.local +#TRANSACTION_REQUESTS_ENDPOINT=transaction-requests-service.local # BACKEND ENDPOINT BACKEND_ENDPOINT=172.17.0.5:4000 @@ -86,6 +83,10 @@ AUTO_ACCEPT_QUOTES=false # cnofirmation call will be required to progress the transfer to quotes state. AUTO_ACCEPT_PARTY=false +# this flag is for testing purpose only. sdk-scheme-adapter is not supposed to receive PUT /participants/{Type}/{ID}, +# but for testing we can enable it by setting this flag to true +AUTO_ACCEPT_PARTICIPANTS_PUT=false + # when set to true, when sending money via the outbound API, the SDK will use the value # of FSPIOP-Source header from the received quote response as the payeeFsp value in the # transfer prepare request body instead of the value received in the payee party lookup. @@ -131,3 +132,9 @@ ALS_ENDPOINT=127.0.0.1:6500 # The incoming transfer request should consists of an ILP packet and a matching condition in this case. # The fulfilment will be generated from the provided ILP packet, and must hash to the provided condition. ALLOW_TRANSFER_WITHOUT_QUOTE=false + +# To enable request for notification on fulfiled transfer +RESERVE_NOTIFICATION=true + +# resources API versions should be string in format: "resouceOneName=1.0,resourceTwoName=1.1" +RESOURCE_VERSIONS="transfers=1.1,participants=1.1" diff --git a/src/.eslintrc.json b/src/.eslintrc.json index af510af61..00d37f729 100644 --- a/src/.eslintrc.json +++ b/src/.eslintrc.json @@ -23,7 +23,7 @@ 2, "always" ], - "no-console": "off", + "no-console": 2, "no-prototype-builtins": "off" } } diff --git a/src/.npmrc b/src/.npmrc deleted file mode 100644 index 43c97e719..000000000 --- a/src/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=false diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index 51233ca14..33af7ed5a 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -3334,6 +3334,8 @@ components: - XDR - XOF - XPF + - XTS + - XXX - YER - ZAR - ZMW @@ -4776,4 +4778,3 @@ components: required: - completedTimestamp - transferState - diff --git a/src/InboundServer/handlers.js b/src/InboundServer/handlers.js index dbb49544e..64fffbd37 100644 --- a/src/InboundServer/handlers.js +++ b/src/InboundServer/handlers.js @@ -14,7 +14,6 @@ 'use strict'; -const util = require('util'); const Model = require('@internal/model').InboundTransfersModel; const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; @@ -28,22 +27,13 @@ const getAuthorizationsById = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers - }; - const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -73,22 +63,13 @@ const postAuthorizations = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if (ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await ctx.state.cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.transactionRequestId}`, req); - ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); - } // use the transfers model to execute asynchronous stages with the switch const thirdpartyTrxnModelIn = new ThirdpartyTrxnModelIn({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -123,7 +104,8 @@ const getParticipantsByTypeAndId = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -155,22 +137,13 @@ const getPartiesByTypeAndId = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers - }; - const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -212,23 +185,13 @@ const postQuotes = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.quoteId}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -259,23 +222,13 @@ const postTransfers = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.transferId}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -305,22 +258,13 @@ const getTransfersById = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if (ctx.state.conf.enableTestFeatures) { - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers - }; - const res = await ctx.state.cache.set( - `request_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -352,23 +296,13 @@ const postTransactionRequests = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.transactionRequestId}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -396,17 +330,6 @@ const postTransactionRequests = async (ctx) => { * request. */ const putAuthorizationsById = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - const idValue = ctx.state.path.params.ID; const authorizationChannel = ctx.state.conf.enablePISPMode @@ -426,17 +349,6 @@ const putAuthorizationsById = async (ctx) => { * Handles a PUT /participants/{ID}. This is a response to a POST /participants request */ const putParticipantsById = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`ac_${ctx.state.path.params.ID}`, { type: 'accountsCreationSuccessfulResponse', @@ -451,17 +363,6 @@ const putParticipantsById = async (ctx) => { * Handles a PUT /participants/{ID}/error. This is an error response to a POST /participants request */ const putParticipantsByIdError = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`ac_${ctx.state.path.params.ID}`, { type: 'accountsCreationErrorResponse', @@ -477,8 +378,40 @@ const putParticipantsByIdError = async (ctx) => { * Handles a PUT /participants/{idType}/{idValue} request */ const putParticipantsByTypeAndId = async (ctx) => { - // SDK does not make participants requests so we should not expect any calls to this method - ctx.response.status = 501; + // Allow putParticipants only for testing purpose when `AUTO_ACCEPT_PARTICIPANTS_PUT` env variable is set to true. + if(ctx.state.conf.autoAcceptParticipantsPut){ + const idType = ctx.state.path.params.Type; + const idValue = ctx.state.path.params.ID; + const idSubValue = ctx.state.path.params.SubId; + + // publish an event onto the cache for subscribers to action + const cacheId = `${idType}_${idValue}` + (idSubValue ? `_${idSubValue}` : ''); + await ctx.state.cache.publish(cacheId, ctx.request.body); + ctx.response.status = 200; + } else { + // SDK does not make participants requests so we should not expect any calls to this method + ctx.response.status = 501; + ctx.response.body = ''; + } +}; + + +/** + * Handles a PUT /participants/{Type}/{ID}/{SubId}/error request. This is an error response to a GET /participants/{Type}/{ID}/{SubId} request + */ +const putParticipantsByTypeAndIdError = async(ctx) => { + const idType = ctx.state.path.params.Type; + const idValue = ctx.state.path.params.ID; + const idSubValue = ctx.state.path.params.SubId; + + // publish an event onto the cache for subscribers to action + // note that we publish the event the same way we publish a success PUT + // the subscriber will notice the body contains an errorInformation property + // and recognise it as an error response + const cacheId = `${idType}_${idValue}` + (idSubValue ? `_${idSubValue}` : ''); + await ctx.state.cache.publish(cacheId, ctx.request.body); + + ctx.response.status = 200; ctx.response.body = ''; }; @@ -488,17 +421,6 @@ const putParticipantsByTypeAndId = async (ctx) => { * request. */ const putPartiesByTypeAndId = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - const idType = ctx.state.path.params.Type; const idValue = ctx.state.path.params.ID; const idSubValue = ctx.state.path.params.SubId; @@ -519,17 +441,6 @@ const putPartiesByTypeAndId = async (ctx) => { * Handles a PUT /quotes/{ID}. This is a response to a POST /quotes request */ const putQuoteById = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`qt_${ctx.state.path.params.ID}`, { type: 'quoteResponse', @@ -540,21 +451,46 @@ const putQuoteById = async (ctx) => { ctx.response.status = 200; }; +/** + * Handles a GET /quotes/{ID} + */ +const getQuoteById = async (ctx) => { + // kick off an asyncronous operation to handle the request + (async () => { + try { + // use the transfers model to execute asynchronous stages with the switch + const model = new Model({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, + }); + + const sourceFspId = ctx.request.headers['fspiop-source']; + + // use the model to handle the request + const response = await model.getQuoteRequest(ctx.state.path.params.ID, sourceFspId); + + // log the result + ctx.state.logger.push({ response }).log('Inbound transfers model handled GET /quotes request'); + } + catch(err) { + // nothing we can do if an error gets thrown back to us here apart from log it and continue + ctx.state.logger.push({ err }).log('Error handling GET /quotes'); + } + })(); + + // Note that we will have passed request validation, JWS etc... by this point + // so it is safe to return 200 + ctx.response.status = 200; + +}; + /** * Handles a PUT /quotes/{ID}. This is a response to a POST /quotes request */ const putTransactionRequestsById = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`txnreq_${ctx.state.path.params.ID}`, { type: 'transactionRequestResponse', @@ -569,17 +505,6 @@ const putTransactionRequestsById = async (ctx) => { * Handles a PUT /transfers/{ID}. This is a response to a POST|PATCH|GET /transfers request */ const putTransfersById = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`tf_${ctx.state.path.params.ID}`, { type: 'transferFulfil', @@ -589,22 +514,38 @@ const putTransfersById = async (ctx) => { ctx.response.status = 200; }; +/** + * Handles a PATCH /transfers/{ID} from the Switch to Payee for successful transfer + */ +const patchTransfersById = async (ctx) => { + const req = { + headers: ctx.request.headers, + data: ctx.request.body + }; + + const idValue = ctx.state.path.params.ID; + + // use the transfers model to execute asynchronous stages with the switch + const model = new Model({ + ...ctx.state.conf, + cache: ctx.state.cache, + logger: ctx.state.logger, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, + }); + + // sends notification to the payee fsp + const response = await model.sendNotificationToPayee(req.data, idValue); + + // log the result + ctx.state.logger.push({response}). + log('Inbound transfers model handled PATCH /transfers/{ID} request'); +}; /** * Handles a PUT /parties/{Type}/{ID}/error request. This is an error response to a GET /parties/{Type}/{ID} request */ const putPartiesByTypeAndIdError = async(ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - const idType = ctx.state.path.params.Type; const idValue = ctx.state.path.params.ID; const idSubValue = ctx.state.path.params.SubId; @@ -631,17 +572,6 @@ const putPartiesByTypeAndIdError = async(ctx) => { * Handles a PUT /quotes/{ID}/error request. This is an error response to a POST /quotes request */ const putQuotesByIdError = async(ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`qt_${ctx.state.path.params.ID}`, { type: 'quoteResponseError', @@ -657,17 +587,6 @@ const putQuotesByIdError = async(ctx) => { * Handles a PUT /transfers/{ID}/error. This is an error response to a POST /transfers request */ const putTransfersByIdError = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`tf_${ctx.state.path.params.ID}`, { type: 'transferError', @@ -685,22 +604,13 @@ const getBulkQuotesById = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if (ctx.state.conf.enableTestFeatures) { - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers - }; - const res = await ctx.state.cache.set( - `request_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -731,23 +641,13 @@ const getBulkQuotesById = async (ctx) => { const postBulkQuotes = async (ctx) => { (async () => { try { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.bulkQuoteId}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -772,17 +672,6 @@ const postBulkQuotes = async (ctx) => { * Handles a PUT /bulkQuotes/{ID}. This is a response to a POST /bulkQuotes request */ const putBulkQuotesById = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`bulkQuotes_${ctx.state.path.params.ID}`, { type: 'bulkQuoteResponse', @@ -797,17 +686,6 @@ const putBulkQuotesById = async (ctx) => { * Handles a PUT /bulkQuotes/{ID}/error request. This is an error response to a POST /bulkQuotes request */ const putBulkQuotesByIdError = async(ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`bulkQuotes_${ctx.state.path.params.ID}`, { type: 'bulkQuoteResponseError', @@ -825,22 +703,13 @@ const getBulkTransfersById = async (ctx) => { // kick off an asyncronous operation to handle the request (async () => { try { - if (ctx.state.conf.enableTestFeatures) { - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers - }; - const res = await ctx.state.cache.set( - `request_${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching request : ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -871,23 +740,13 @@ const getBulkTransfersById = async (ctx) => { const postBulkTransfers = async (ctx) => { (async () => { try { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.REQUEST_PREFIX}${ctx.request.body.bulkTransferId}`, req); - ctx.state.logger.log(`Caching request: ${util.inspect(res)}`); - } - // use the transfers model to execute asynchronous stages with the switch const model = new Model({ ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, + resourceVersions: ctx.resourceVersions, }); const sourceFspId = ctx.request.headers['fspiop-source']; @@ -912,17 +771,6 @@ const postBulkTransfers = async (ctx) => { * Handles a PUT /bulkTransfers/{ID}. This is a response to a POST /bulkTransfers request */ const putBulkTransfersById = async (ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`bulkTransfer_${ctx.state.path.params.ID}`, { type: 'bulkTransferResponse', @@ -937,17 +785,6 @@ const putBulkTransfersById = async (ctx) => { * Handles a PUT /bulkTransfers/{ID}/error request. This is an error response to a POST /bulkTransfers request */ const putBulkTransfersByIdError = async(ctx) => { - if(ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ctx.state.cache.publish(`bulkTransfer_${ctx.state.path.params.ID}`, { type: 'bulkTransferResponseError', @@ -963,17 +800,6 @@ const putBulkTransfersByIdError = async(ctx) => { * This is response to a POST /thirdpartyRequests/transactions request */ const putThirdPartyReqTransactionsById = async (ctx) => { - if (ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await ctx.state.cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ThirdpartyTrxnModelOut.publishNotifications(ctx.state.cache, ctx.state.path.params.ID, { type: 'thirdpartyTransactionsReqResponse', @@ -989,17 +815,6 @@ const putThirdPartyReqTransactionsById = async (ctx) => { * This is error response to POST /thirdpartyRequests/transactions request */ const putThirdPartyReqTransactionsByIdError = async (ctx) => { - if (ctx.state.conf.enableTestFeatures) { - const cache = ctx.state.cache; - // we are in test mode so cache the request - const req = { - headers: ctx.request.headers, - data: ctx.request.body - }; - const res = await ctx.state.cache.set(`${cache.CALLBACK_PREFIX}${ctx.state.path.params.ID}`, req); - ctx.state.logger.log(`Caching callback: ${util.inspect(res)}`); - } - // publish an event onto the cache for subscribers to action await ThirdpartyTrxnModelOut.publishNotifications(ctx.state.cache, ctx.state.path.params.ID, { type: 'thirdpartyTransactionsReqErrorResponse', @@ -1053,10 +868,13 @@ module.exports = { put: putParticipantsByTypeAndId, get: getParticipantsByTypeAndId }, - '/participants/{Type}/{SubId}/{ID}': { + '/participants/{Type}/{ID}/{SubId}': { put: putParticipantsByTypeAndId, get: getParticipantsByTypeAndId }, + '/participants/{Type}/{ID}/{SubId}/error': { + put: putParticipantsByTypeAndIdError + }, '/participants/{ID}/error': { put: putParticipantsByIdError }, @@ -1080,7 +898,8 @@ module.exports = { post: postQuotes }, '/quotes/{ID}': { - put: putQuoteById + put: putQuoteById, + get: getQuoteById }, '/quotes/{ID}/error': { put: putQuotesByIdError @@ -1091,7 +910,7 @@ module.exports = { '/transfers/{ID}': { get: getTransfersById, put: putTransfersById, - patch: putTransfersById + patch: patchTransfersById }, '/transfers/{ID}/error': { put: putTransfersByIdError diff --git a/src/InboundServer/index.js b/src/InboundServer/index.js index c6c06c90b..0df576eb1 100644 --- a/src/InboundServer/index.js +++ b/src/InboundServer/index.js @@ -10,152 +10,68 @@ const Koa = require('koa'); +const assert = require('assert').strict; const https = require('https'); const http = require('http'); const yaml = require('js-yaml'); const fs = require('fs'); const path = require('path'); +const EventEmitter = require('events'); const { WSO2Auth } = require('@mojaloop/sdk-standard-components'); -const { Logger, Transports } = require('@internal/log'); -const Cache = require('@internal/cache'); const Validate = require('@internal/validate'); const router = require('@internal/router'); const handlers = require('./handlers'); const middlewares = require('./middlewares'); -class InboundServer { - constructor(conf) { +class InboundApi extends EventEmitter { + constructor(conf, logger, cache, validator) { + super({ captureExceptions: true }); this._conf = conf; - this._api = null; - this._server = null; - this._logger = null; - this._jwsVerificationKeys = {}; - } - - async setupApi() { - this._api = new Koa(); - this._logger = await this._createLogger(); - - this._cache = await this._createCache(); - - const specPath = path.join(__dirname, 'api.yaml'); - const apiSpecs = yaml.load(fs.readFileSync(specPath)); - const validator = new Validate(); - await validator.initialise(apiSpecs); - - this._wso2Auth = new WSO2Auth({ - ...this._conf.wso2Auth, - logger: this._logger, - tlsCreds: this._conf.tls.outbound.mutualTLS.enabled && this._conf.tls.outbound.creds, + this._cache = cache; + this._wso2 = { + auth: new WSO2Auth({ + ...conf.wso2.auth, + logger, + tlsCreds: conf.outbound.tls.mutualTLS.enabled && conf.outbound.tls.creds, + }), + retryWso2AuthFailureTimes: conf.wso2.requestAuthFailureRetryTimes, + }; + this._wso2.auth.on('error', (msg) => { + this.emit('error', 'WSO2 auth error in InboundApi', msg); }); - this._api.use(middlewares.createErrorHandler()); - this._api.use(middlewares.createRequestIdGenerator()); - this._api.use(middlewares.createHeaderValidator(this._logger)); - - if(this._conf.validateInboundJws) { - const jwsExclusions = []; - if (!this._conf.validateInboundPutPartiesJws) { - jwsExclusions.push('putParties'); - } - this._jwsVerificationKeys = this._getJwsKeys(); - this._api.use(middlewares.createJwsValidator(this._logger, this._jwsVerificationKeys, jwsExclusions)); + if (conf.validateInboundJws) { + this._jwsVerificationKeys = InboundApi._GetJwsKeys(conf.jwsVerificationKeysDirectory); } - - const sharedState = { cache: this._cache, wso2Auth: this._wso2Auth, conf: this._conf }; - this._api.use(middlewares.createLogger(this._logger, sharedState)); - - this._api.use(middlewares.createRequestValidator(validator)); - this._api.use(router(handlers)); - this._api.use(middlewares.createResponseBodyHandler()); - - this._server = this._createServer(); - return this._server; + this._api = InboundApi._SetupApi({ + conf, + logger, + validator, + cache, + jwsVerificationKeys: this._jwsVerificationKeys, + wso2: this._wso2, + }); } async start() { this._startJwsWatcher(); - await this._cache.connect(); if (!this._conf.testingDisableWSO2AuthStart) { - await this._wso2Auth.start(); - } - if (!this._conf.testingDisableServerStart) { - await new Promise((resolve) => this._server.listen(this._conf.inboundPort, resolve)); - this._logger.log(`Serving inbound API on port ${this._conf.inboundPort}`); + await this._wso2.auth.start(); } } - async stop() { - if (!this._server) { - return; + stop() { + this._wso2.auth.stop(); + if (this._keyWatcher) { + this._keyWatcher.close(); + this._keyWatcher = null; } - await new Promise(resolve => this._server.close(resolve)); - this._wso2Auth.stop(); - await this._cache.disconnect(); - this._stopJwsWatcher(); - console.log('inbound shut down complete'); - } - - async _createLogger() { - const transports = await Promise.all([Transports.consoleDir()]); - // Set up a logger for each running server - return new Logger({ - context: { - app: 'mojaloop-sdk-inbound-api' - }, - space: this._conf.logIndent, - transports, - }); } - async _createCache() { - const transports = await Promise.all([Transports.consoleDir()]); - const logger = new Logger({ - context: { - app: 'mojaloop-sdk-inboundCache' - }, - space: this._conf.logIndent, - transports, - }); - - const cacheConfig = { - ...this._conf.cacheConfig, - logger - }; - - return new Cache(cacheConfig); - } - - _createServer() { - let server; - // If config specifies TLS, start an HTTPS server; otherwise HTTP - if (this._conf.tls.inbound.mutualTLS.enabled) { - const inboundHttpsOpts = { - ...this._conf.tls.inbound.creds, - requestCert: true, - rejectUnauthorized: true // no effect if requestCert is not true - }; - server = https.createServer(inboundHttpsOpts, this._api.callback()); - } else { - server = http.createServer(this._api.callback()); - } - return server; - } - - _getJwsKeys() { - const keys = {}; - if (this._conf.jwsVerificationKeysDirectory) { - fs.readdirSync(this._conf.jwsVerificationKeysDirectory) - .filter(f => f.endsWith('.pem')) - .forEach(f => { - const keyName = path.basename(f, '.pem'); - const keyPath = path.join(this._conf.jwsVerificationKeysDirectory, f); - keys[keyName] = fs.readFileSync(keyPath); - }); - } - return keys; + callback() { + return this._api.callback(); } _startJwsWatcher() { @@ -182,15 +98,108 @@ class InboundServer { } }; if (this._conf.jwsVerificationKeysDirectory) { - this.keyWatcher = fs.watch(this._conf.jwsVerificationKeysDirectory, watchHandler); + this._keyWatcher = fs.watch(this._conf.jwsVerificationKeysDirectory, watchHandler); } } - _stopJwsWatcher() { - if (this.keyWatcher) { - this.keyWatcher.close(); + static _SetupApi({ conf, logger, validator, cache, jwsVerificationKeys, wso2 }) { + const api = new Koa(); + + api.use(middlewares.createErrorHandler(logger)); + api.use(middlewares.createRequestIdGenerator()); + api.use(middlewares.createHeaderValidator(logger)); + if (conf.validateInboundJws) { + const jwsExclusions = conf.validateInboundPutPartiesJws ? [] : ['putParties']; + api.use(middlewares.createJwsValidator(logger, jwsVerificationKeys, jwsExclusions)); + } + + api.use(middlewares.applyState({ cache, wso2, conf })); + api.use(middlewares.createLogger(logger)); + api.use(middlewares.createRequestValidator(validator)); + api.use(middlewares.assignFspiopIdentifier()); + if (conf.enableTestFeatures) { + api.use(middlewares.cacheRequest(cache)); + } + api.use(router(handlers)); + api.use(middlewares.createResponseBodyHandler()); + + api.context.resourceVersions = conf.resourceVersions; + + return api; + } + + static _GetJwsKeys(fromDir) { + const keys = {}; + if (fromDir) { + fs.readdirSync(fromDir) + .filter(f => f.endsWith('.pem')) + .forEach(f => { + const keyName = path.basename(f, '.pem'); + const keyPath = path.join(fromDir, f); + keys[keyName] = fs.readFileSync(keyPath); + }); + } + return keys; + } +} + +class InboundServer extends EventEmitter { + constructor(conf, logger, cache) { + super({ captureExceptions: true }); + this._conf = conf; + this._validator = new Validate(); + this._logger = logger; + this._api = new InboundApi( + conf, + this._logger.push({ component: 'api' }), + cache, + this._validator + ); + this._api.on('error', (...args) => { + this.emit('error', ...args); + }); + this._server = this._createServer( + conf.inbound.tls.mutualTLS.enabled, + conf.inbound.tls.creds, + this._api.callback() + ); + } + + async start() { + assert(!this._server.listening, 'Server already listening'); + const specPath = path.join(__dirname, 'api.yaml'); + const apiSpecs = yaml.load(fs.readFileSync(specPath)); + await this._validator.initialise(apiSpecs); + await this._api.start(); + await new Promise((resolve) => this._server.listen(this._conf.inbound.port, resolve)); + this._logger.log(`Serving inbound API on port ${this._conf.inbound.port}`); + } + + async stop() { + if (this._server) { + await new Promise(resolve => this._server.close(resolve)); + this._server = null; + } + if (this._api) { + await this._api.stop(); + this._api = null; } + this._logger.log('inbound shut down complete'); } + + _createServer(tlsEnabled, tlsCreds, handler) { + if (!tlsEnabled) { + return http.createServer(handler); + } + + const inboundHttpsOpts = { + ...tlsCreds, + requestCert: true, + rejectUnauthorized: true // no effect if requestCert is not true + }; + return https.createServer(inboundHttpsOpts, handler); + } + } module.exports = InboundServer; diff --git a/src/InboundServer/middlewares.js b/src/InboundServer/middlewares.js index 052587389..77f57da3f 100644 --- a/src/InboundServer/middlewares.js +++ b/src/InboundServer/middlewares.js @@ -8,7 +8,6 @@ * James Bush - james.bush@modusbox.com * **************************************************************************/ -const util = require('util'); const coBody = require('co-body'); const randomPhrase = require('@internal/randomphrase'); @@ -18,16 +17,131 @@ const { Jws, Errors } = require('@mojaloop/sdk-standard-components'); * Log raw to console as a last resort * @return {Function} */ -const createErrorHandler = () => async (ctx, next) => { +const createErrorHandler = (logger) => async (ctx, next) => { try { await next(); } catch (err) { // TODO: return a 500 here if the response has not already been sent? - console.log(`Error caught in catchall: ${err.stack || util.inspect(err, { depth: 10 })}`); + logger.push({ err }).log('Error caught in catchall'); } }; +/** + * tag each incoming request with the FSPIOP identifier from it's path or body + * @return {Function} + */ +const assignFspiopIdentifier = () => async (ctx, next) => { + const getters = { + '/authorizations/{ID}': { + get: () => ctx.state.path.params.ID, + put: () => ctx.state.path.params.ID, + }, + '/bulkQuotes': { + post: () => ctx.request.body.bulkQuoteId, + }, + '/bulkQuotes/{ID}': { + get: () => ctx.state.path.params.ID, + put: () => ctx.state.path.params.ID, + }, + '/bulkQuotes/{ID}/error': { + put: () => ctx.state.path.params.ID, + }, + '/bulkTransfers': { + post: () => ctx.request.body.bulkTransferId, + }, + '/bulkTransfers/{ID}': { + get: () => ctx.state.path.params.ID, + put: () => ctx.state.path.params.ID, + }, + '/bulkTransfers/{ID}/error': { + put: () => ctx.state.path.params.ID, + }, + '/participants/{ID}': { + put: () => ctx.state.path.params.ID, + }, + '/participants/{Type}/{ID}': { + get: () => ctx.state.path.params.ID, + }, + '/participants/{Type}/{ID}/{SubId}': { + get: () => ctx.state.path.params.ID, + put: () => ctx.state.path.params.ID, + }, + '/participants/{Type}/{ID}/{SubId}/error': { + put: () => ctx.state.path.params.ID, + }, + '/participants/{ID}/error': { + put: () => ctx.state.path.params.ID, + }, + '/parties/{Type}/{ID}': { + get: () => ctx.state.path.params.ID, + put: () => ctx.state.path.params.ID, + }, + '/parties/{Type}/{ID}/{SubId}': { + get: () => ctx.state.path.params.ID, + put: () => ctx.state.path.params.ID, + }, + '/parties/{Type}/{ID}/error': { + put: () => ctx.state.path.params.ID, + }, + '/parties/{Type}/{ID}/{SubId}/error': { + put: () => ctx.state.path.params.ID, + }, + '/quotes': { + post: () => ctx.request.body.quoteId, + }, + '/quotes/{ID}': { + put: () => ctx.state.path.params.ID, + }, + '/quotes/{ID}/error': { + put: () => ctx.state.path.params.ID, + }, + '/transfers': { + post: () => ctx.request.body.transferId, + }, + '/transfers/{ID}': { + get: () => ctx.state.path.params.ID, + put: () => ctx.state.path.params.ID, + patch: () => ctx.state.path.params.ID, + }, + '/transfers/{ID}/error': { + put: () => ctx.state.path.params.ID, + }, + '/transactionRequests': { + post: () => ctx.request.body.transactionRequestId, + }, + '/transactionRequests/{ID}': { + put: () => ctx.state.path.params.ID, + } + }[ctx.state.path.pattern]; + if (getters) { + const getter = getters[ctx.method.toLowerCase()]; + if (getter) { + ctx.state.fspiopId = getter(ctx.request); + } + } + await next(); +}; + + +/** + * cache incoming requests and callbacks + * @return {Function} + */ +const cacheRequest = (cache) => async (ctx, next) => { + if (ctx.state.fspiopId) { + const req = { + headers: ctx.request.headers, + data: ctx.request.body, + }; + const prefix = ctx.method.toLowerCase() === 'put' ? cache.CALLBACK_PREFIX : cache.REQUEST_PREFIX; + const res = await cache.set(`${prefix}${ctx.state.fspiopId}`, req); + ctx.state.logger.push({ res }).log('Caching request'); + } + await next(); +}; + + /** * tag each incoming request with a unique identifier * @return {Function} @@ -49,7 +163,9 @@ const createHeaderValidator = (logger) => async (ctx, next) => { 'application/vnd.interoperability.parties+json;version=1.0', 'application/vnd.interoperability.participants+json;version=1.0', 'application/vnd.interoperability.quotes+json;version=1.0', + 'application/vnd.interoperability.quotes+json;version=1.1', 'application/vnd.interoperability.bulkQuotes+json;version=1.0', + 'application/vnd.interoperability.bulkQuotes+json;version=1.1', 'application/vnd.interoperability.bulkTransfers+json;version=1.0', 'application/vnd.interoperability.transactionRequests+json;version=1.0', 'application/vnd.interoperability.transfers+json;version=1.0', @@ -122,17 +238,25 @@ const createJwsValidator = (logger, keys, exclusions) => { }; +/** + * Add request state. + * TODO: this should probably be app context: + * https://github.com/koajs/koa/blob/master/docs/api/index.md#appcontext + * @param sharedState + * @return {Function} + */ +const applyState = (sharedState) => async (ctx, next) => { + Object.assign(ctx.state, sharedState); + await next(); +}; + + /** * Add a log context for each request, log the receipt and handling thereof * @param logger - * @param sharedState * @return {Function} */ -const createLogger = (logger, sharedState) => async (ctx, next) => { - ctx.state = { - ...ctx.state, - ...sharedState, - }; +const createLogger = (logger) => async (ctx, next) => { ctx.state.logger = logger.push({ request: { id: ctx.request.id, path: ctx.path, @@ -198,6 +322,9 @@ const createResponseBodyHandler = () => async (ctx, next) => { module.exports = { + applyState, + assignFspiopIdentifier, + cacheRequest, createErrorHandler, createRequestIdGenerator, createHeaderValidator, diff --git a/src/OAuthTestServer/index.js b/src/OAuthTestServer/index.js index 7773ca1e7..2c1ffa92d 100644 --- a/src/OAuthTestServer/index.js +++ b/src/OAuthTestServer/index.js @@ -10,10 +10,10 @@ 'use strict'; +const http = require('http'); const Koa = require('koa'); const koaBody = require('koa-body'); const OAuthServer = require('koa2-oauth-server'); -const { Logger, Transports } = require('@internal/log'); const { InMemoryCache } = require('./model'); class OAuthTestServer { @@ -23,56 +23,42 @@ class OAuthTestServer { * @param {number} conf.port OAuth server listen port * @param {string} conf.clientKey Customer Key * @param {String} conf.clientSecret Customer Secret - * @param {String} conf.logIndent + * @param {Logger} conf.logger Logger */ - constructor(conf) { - this._conf = conf; + constructor({ port, clientKey, clientSecret, logger }) { this._api = null; - this._logger = null; + this._port = port; + this._logger = logger; + this._api = OAuthTestServer._SetupApi({ clientKey, clientSecret }); + this._server = http.createServer(this._api.callback()); } async start() { - await new Promise((resolve) => this._api.listen(this._conf.port, resolve)); - this._logger.log(`Serving OAuth2 Test Server on port ${this._conf.port}`); + if (this._server.listening) { + return; + } + await new Promise((resolve) => this._server.listen(this._port, resolve)); + this._logger.push({ port: this._port }).log('Serving OAuth2 Test Server'); } async stop() { - if (this._api) { - return; - } - await new Promise(resolve => this._api.close(resolve)); - console.log('OAuth2 Test Server shut down complete'); + await new Promise(resolve => this._server.close(resolve)); + this._logger.log('OAuth2 Test Server shut down complete'); } - async setupApi() { - this._api = new Koa(); - this._logger = await this._createLogger(); + static _SetupApi({ clientKey, clientSecret }) { + const result = new Koa(); - this._api.oauth = new OAuthServer({ - model: new InMemoryCache(this._conf), + result.oauth = new OAuthServer({ + model: new InMemoryCache({ clientKey, clientSecret }), accessTokenLifetime: 60 * 60, allowBearerTokensInQueryString: true, }); - this._api.use(koaBody()); - this._api.use(this._api.oauth.token()); + result.use(koaBody()); + result.use(result.oauth.token()); - this._api.use(async (next) => { - this.body = 'Secret area'; - await next(); - }); - } - - async _createLogger() { - const transports = await Promise.all([Transports.consoleDir()]); - // Set up a logger for each running server - return new Logger({ - context: { - app: 'mojaloop-sdk-oauth-test-server' - }, - space: this._conf.logIndent, - transports, - }); + return result; } } diff --git a/src/OAuthTestServer/model.js b/src/OAuthTestServer/model.js index 2aef819c1..39588df3c 100644 --- a/src/OAuthTestServer/model.js +++ b/src/OAuthTestServer/model.js @@ -10,15 +10,13 @@ class InMemoryCache { /** - * - * @param {Object} opts * @param {string} opts.clientKey Customer Key * @param {String} opts.clientSecret Customer Secret */ - constructor(opts) { + constructor({ clientKey : clientId, clientSecret }) { this.clients = [{ - clientId : opts.clientKey, - clientSecret : opts.clientSecret, + clientId, + clientSecret, grants: [ 'client_credentials' ], diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index e430971b0..8e0d34ff6 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -25,7 +25,7 @@ paths: - Health responses: 200: - description: Returns empty body if the scheme adapter outbound transfers service is running. + description: Returns empty body if the scheme adapter outbound transfers service is running. /transfers: post: summary: Sends money from one account to another @@ -381,7 +381,7 @@ paths: # $ref: '#/components/responses/authorizationsPostServerError' # 504: # $ref: '#/components/responses/authorizationsPostTimeout' - + /thirdpartyRequests/transactions: post: summary: Initiates a third party request transaction. @@ -405,7 +405,7 @@ paths: # $ref: '#/components/responses/' # 504: # $ref: '#/components/responses/' - + /thirdpartyRequests/transactions/{transactionRequestId}: get: summary: Retrieves information for a specific thirdparty request transaction. @@ -429,7 +429,7 @@ paths: # application/json: # schema: # $ref: '#/components/schemas/' - + /parties/{Type}/{ID}: parameters: - $ref: '#/components/parameters/Type' @@ -651,6 +651,8 @@ components: - XDR - XOF - XPF + - XTS + - XXX - YER - ZAR - ZMW @@ -1733,14 +1735,14 @@ components: ^(\+|-)?(?:90(?:(?:\.0{1,6})?)|(?:[0-9]|[1-8][0-9])(?:(?:\.[0-9]{1,6})?))$ description: > The API data type Latitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. longitude: type: string pattern: >- ^(\+|-)?(?:180(?:(?:\.0{1,6})?)|(?:[0-9]|[1-9][0-9]|1[0-7][0-9])(?:(?:\.[0-9]{1,6})?))$ description: >- The API data type Longitude is a JSON String in a lexical format that is - restricted by a regular expression for interoperability reasons. + restricted by a regular expression for interoperability reasons. ilpCondition: type: string @@ -2144,7 +2146,7 @@ components: transactionRequestState: $ref: '#/components/schemas/mojaloopTransactionRequestState' description: State of the transaction request` - + Party: title: Party type: object @@ -2221,7 +2223,7 @@ components: maxLength: 128 pattern: '^(?!\s*$)[\w .,''-]{1,128}$' description: Middle name of the Party (Name Type). - + LastName: title: LastName type: string @@ -2229,7 +2231,7 @@ components: maxLength: 128 pattern: '^(?!\s*$)[\w .,''-]{1,128}$' description: Last name of the Party (Name Type). - + DateOfBirth: title: DateofBirth (type Date) type: string @@ -2641,7 +2643,7 @@ components: required: - key - value - + ExtensionList: title: ExtensionList type: object @@ -2784,7 +2786,7 @@ components: PartiesByIdResponse: description: PartiesByIdResponse - content: + content: application/json: schema: type: object @@ -2801,7 +2803,7 @@ components: PartiesByIdError404: description: PartiesByIdError404 - content: + content: application/json: schema: type: object @@ -2868,4 +2870,4 @@ components: required: true schema: type: string - + diff --git a/src/OutboundServer/handlers.js b/src/OutboundServer/handlers.js index ced6dfb46..613812c44 100644 --- a/src/OutboundServer/handlers.js +++ b/src/OutboundServer/handlers.js @@ -112,7 +112,7 @@ const postTransfers = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); // initialize the transfer model and start it running @@ -144,7 +144,7 @@ const getTransfers = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); // initialize the transfer model and start it running @@ -172,7 +172,7 @@ const putTransfers = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); // TODO: check the incoming body to reject party or quote when requested to do so @@ -206,7 +206,7 @@ const postBulkTransfers = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); await model.initialize(bulkTransferRequest); @@ -237,7 +237,7 @@ const getBulkTransfers = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); await model.initialize(bulkTransferRequest); @@ -266,7 +266,7 @@ const postBulkQuotes = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); await model.initialize(bulkQuoteRequest); @@ -297,7 +297,7 @@ const getBulkQuoteById = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); await model.initialize(bulkQuoteRequest); @@ -327,7 +327,7 @@ const postRequestToPayTransfer = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); // initialize the transfer model and start it running @@ -354,7 +354,7 @@ const putRequestToPayTransfer = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); // TODO: check the incoming body to reject party or quote when requested to do so @@ -385,9 +385,10 @@ const postAccounts = async (ctx) => { try { const model = new AccountsModel({ ...ctx.state.conf, + tls: ctx.state.conf.outbound.tls, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); const state = { @@ -419,7 +420,7 @@ const postRequestToPay = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }); // initialize the transfer model and start it running @@ -452,7 +453,7 @@ const postAuthorizations = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }; const cacheKey = `post_authorizations_${authorizationsRequest.transactionRequestId}`; @@ -486,7 +487,7 @@ const getThirdpartyRequestsTransactions = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }; const cacheKey = `get_thirdparty_requests_transactions_${thirdpartyRequestsTransactionRequest}`; @@ -519,7 +520,7 @@ const postThirdpartyRequestsTransactions = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }; const cacheKey = `post_thirdparty_requests_transactions_${ctx.request.body.transactionRequestId}`; @@ -550,7 +551,7 @@ const getPartiesByTypeAndId = async (ctx) => { ...ctx.state.conf, cache: ctx.state.cache, logger: ctx.state.logger, - wso2Auth: ctx.state.wso2Auth, + wso2: ctx.state.wso2, }; const cacheKey = PartiesModel.generateKey(type, id, subId); diff --git a/src/OutboundServer/index.js b/src/OutboundServer/index.js index 8a91eb3d2..ea597ed5c 100644 --- a/src/OutboundServer/index.js +++ b/src/OutboundServer/index.js @@ -15,10 +15,9 @@ const koaBody = require('koa-body'); const yaml = require('js-yaml'); const fs = require('fs'); const path = require('path'); +const EventEmitter = require('events'); const { WSO2Auth } = require('@mojaloop/sdk-standard-components'); -const { Logger, Transports } = require('@internal/log'); -const Cache = require('@internal/cache'); const Validate = require('@internal/validate'); const router = require('@internal/router'); @@ -27,112 +26,106 @@ const middlewares = require('./middlewares'); const endpointRegex = /\/.*/g; -class OutboundServer { - constructor(conf) { - this._conf = conf; - this._api = null; - this._server = null; - this._logger = null; - } - - async setupApi() { +class OutboundApi extends EventEmitter { + constructor(conf, logger, cache, validator) { + super({ captureExceptions: true }); + this._logger = logger; this._api = new Koa(); - this._logger = await this._createLogger(); - - this._cache = await this._createCache(); - - const specPath = path.join(__dirname, 'api.yaml'); - const apiSpecs = yaml.load(fs.readFileSync(specPath)); - const validator = new Validate(); - await validator.initialise(apiSpecs); + this._conf = conf; + this._cache = cache; - this._wso2Auth = new WSO2Auth({ - ...this._conf.wso2Auth, - logger: this._logger, - tlsCreds: this._conf.tls.outbound.mutualTLS.enabled && this._conf.tls.outbound.creds, + this._wso2 = { + auth: new WSO2Auth({ + ...this._conf.wso2.auth, + logger: this._logger, + tlsCreds: this._conf.outbound.tls.mutualTLS.enabled && this._conf.outbound.tls.creds, + }), + retryWso2AuthFailureTimes: conf.wso2.requestAuthFailureRetryTimes, + }; + this._wso2.auth.on('error', (msg) => { + this.emit('error', 'WSO2 auth error in OutboundApi', msg); }); - this._api.use(middlewares.createErrorHandler()); - - // outbound always expects application/json - this._api.use(koaBody()); - - const sharedState = { cache: this._cache, wso2Auth: this._wso2Auth, conf: this._conf }; - this._api.use(middlewares.createLogger(this._logger, sharedState)); + this._api.use(middlewares.createErrorHandler(this._logger)); + this._api.use(middlewares.createRequestIdGenerator()); + this._api.use(koaBody()); // outbound always expects application/json + this._api.use(middlewares.applyState({ cache, wso2: this._wso2, conf })); + this._api.use(middlewares.createLogger(this._logger)); //Note that we strip off any path on peerEndpoint config after the origin. //this is to allow proxy routed requests to hit any path on the peer origin //irrespective of any base path on the PEER_ENDPOINT setting - if (this._conf.proxyConfig) { + if (conf.proxyConfig) { this._api.use(middlewares.createProxy({ - ...this._conf, - peerEndpoint: this._conf.peerEndpoint.replace(endpointRegex, ''), - proxyConfig: this._conf.proxyConfig, + ...conf, + peerEndpoint: conf.peerEndpoint.replace(endpointRegex, ''), + proxyConfig: conf.proxyConfig, logger: this._logger, - wso2Auth: this._wso2Auth, + wso2Auth: this._wso2.auth, + tls: conf.outbound.tls, })); } this._api.use(middlewares.createRequestValidator(validator)); this._api.use(router(handlers)); - - this._server = this._createServer(); - return this._server; } async start() { - await this._cache.connect(); if (!this._conf.testingDisableWSO2AuthStart) { - await this._wso2Auth.start(); - } - if (!this._conf.testingDisableServerStart) { - await new Promise((resolve) => this._server.listen(this._conf.outboundPort, resolve)); - this._logger.log(`Serving outbound API on port ${this._conf.outboundPort}`); + await this._wso2.auth.start(); } } async stop() { - if (!this._server) { - return; - } - await new Promise(resolve => this._server.close(resolve)); - this._wso2Auth.stop(); - await this._cache.disconnect(); - console.log('outbound shut down complete'); + this._wso2.auth.stop(); } - async _createCache() { - const transports = await Promise.all([Transports.consoleDir()]); - const logger = new Logger({ - context: { - app: 'mojaloop-sdk-outboundCache' - }, - space: this._conf.logIndent, - transports, + callback() { + return this._api.callback(); + } +} + +class OutboundServer extends EventEmitter { + constructor(conf, logger, cache) { + super({ captureExceptions: true }); + this._validator = new Validate(); + this._conf = conf; + this._logger = logger; + this._server = null; + this._api = new OutboundApi( + conf, + this._logger.push({ component: 'api' }), + cache, + this._validator + ); + this._api.on('error', (...args) => { + this.emit('error', ...args); }); + this._server = http.createServer(this._api.callback()); + } - const cacheConfig = { - ...this._conf.cacheConfig, - logger - }; + async start() { + await this._api.start(); - return new Cache(cacheConfig); - } + const specPath = path.join(__dirname, 'api.yaml'); + const apiSpecs = yaml.load(fs.readFileSync(specPath)); + await this._validator.initialise(apiSpecs); - async _createLogger() { - const transports = await Promise.all([Transports.consoleDir()]); - // Set up a logger for each running server - return new Logger({ - context: { - app: 'mojaloop-sdk-outbound-api' - }, - space: this._conf.logIndent, - transports, - }); + await new Promise((resolve) => this._server.listen(this._conf.outbound.port, resolve)); + + this._logger.log(`Serving outbound API on port ${this._conf.outbound.port}`); } - _createServer() { - return http.createServer(this._api.callback()); + async stop() { + if (this._server) { + await new Promise(resolve => this._server.close(resolve)); + this._server = null; + } + if (this._api) { + await this._api.stop(); + this._api = null; + } + this._logger.log('Shut down complete'); } } diff --git a/src/OutboundServer/middlewares.js b/src/OutboundServer/middlewares.js index 22fab02d6..341cc978e 100644 --- a/src/OutboundServer/middlewares.js +++ b/src/OutboundServer/middlewares.js @@ -9,49 +9,9 @@ **************************************************************************/ -const util = require('util'); -const randomPhrase = require('@internal/randomphrase'); const { ProxyModel } = require('@internal/model'); - - -/** - * Log raw to console as a last resort - * @return {Function} - */ -const createErrorHandler = () => async (ctx, next) => { - try { - await next(); - } catch (err) { - // TODO: return a 500 here if the response has not already been sent? - console.log(`Error caught in catchall: ${err.stack || util.inspect(err, { depth: 10 })}`); - } -}; - - -/** - * Add a log context for each request, log the receipt and handling thereof - * @param logger - * @param sharedState - * @return {Function} - */ -const createLogger = (logger, sharedState) => async (ctx, next) => { - ctx.state = { - ...ctx.state, - ...sharedState, - }; - ctx.state.logger = logger.push({ request: { - id: randomPhrase(), - path: ctx.path, - method: ctx.method - }}); - ctx.state.logger.log('Request received'); - try { - await next(); - } catch (err) { - ctx.state.logger.push(err).log('Error'); - } - await ctx.state.logger.log('Request processed'); -}; +const { applyState, createErrorHandler, createLogger, createRequestIdGenerator } = + require('../InboundServer/middlewares'); /** @@ -98,6 +58,8 @@ const createProxy = (opts) => { }; module.exports = { + applyState, + createRequestIdGenerator, createErrorHandler, createLogger, createRequestValidator, diff --git a/src/TestServer/index.js b/src/TestServer/index.js index e9e70f4f3..f3dea01ce 100644 --- a/src/TestServer/index.js +++ b/src/TestServer/index.js @@ -11,16 +11,11 @@ const Koa = require('koa'); const ws = require('ws'); -const https = require('https'); const http = require('http'); const yaml = require('js-yaml'); -const fs = require('fs'); +const fs = require('fs').promises; const path = require('path'); -const { WSO2Auth } = require('@mojaloop/sdk-standard-components'); -const { Logger, Transports } = require('@internal/log'); -const Cache = require('@internal/cache'); - const Validate = require('@internal/validate'); const router = require('@internal/router'); const handlers = require('./handlers'); @@ -35,137 +30,39 @@ const getWsIp = (req) => [ ) ]; -class TestServer { - constructor(conf) { - this._conf = conf; - this._api = null; - this._server = null; - this._logger = null; - } - - async setupApi() { +class TestApi { + constructor(logger, validator, cache) { this._api = new Koa(); - this._wsClients = new Map(); - this._wsapi = this._createWsServer(); - - this._logger = await this._createLogger(); - - this._cache = await this._createCache(); - - const specPath = path.join(__dirname, 'api.yaml'); - const apiSpecs = yaml.load(fs.readFileSync(specPath)); - const validator = new Validate(); - await validator.initialise(apiSpecs); - - this._wso2Auth = new WSO2Auth({ - ...this._conf.wso2Auth, - logger: this._logger, - tlsCreds: this._conf.tls.test.mutualTLS.enabled && this._conf.tls.test.creds, - }); - this._api.use(middlewares.createErrorHandler()); + this._api.use(middlewares.createErrorHandler(logger)); this._api.use(middlewares.createRequestIdGenerator()); - const sharedState = { cache: this._cache, wso2Auth: this._wso2Auth, conf: this._conf }; - this._api.use(middlewares.createLogger(this._logger, sharedState)); + this._api.use(middlewares.applyState({ cache })); + this._api.use(middlewares.createLogger(logger)); this._api.use(middlewares.createRequestValidator(validator)); this._api.use(router(handlers)); this._api.use(middlewares.createResponseBodyHandler()); - - this._server = this._createServer(); - return this._server; - } - - async start() { - await this._cache.connect(); - this._cache.subscribe(this._cache.EVENT_SET, this._handleCacheKeySet.bind(this)); - this._cache.setTestMode(true); - if (!this._conf.testingDisableWSO2AuthStart) { - await this._wso2Auth.start(); - } - this._server.on('upgrade', (req, socket, head) => { - this._wsapi.handleUpgrade(req, socket, head, (ws) => - this._wsapi.emit('connection', ws, req)); - }); - await new Promise((resolve) => this._server.listen(this._conf.testPort, resolve)); - this._logger.log(`Serving test API on port ${this._conf.testPort}`); - } - - async stop() { - if (!this._server) { - return; - } - await new Promise(resolve => this._wsapi.close(resolve)); - // If we don't want for all clients to close before shutting down, the socket close - // handlers will be called after we return from this function, resulting in behaviour - // occurring after the server tells the user it has shutdown. - await Promise.all([...this._wsClients.keys()].map(socket => - new Promise(resolve => socket.on('close', resolve)) - )); - await new Promise(resolve => this._server.close(resolve)); - this._wso2Auth.stop(); - await this._cache.disconnect(); - console.log('api shut down complete'); - } - - async _createLogger() { - const transports = await Promise.all([Transports.consoleDir()]); - // Set up a logger for each running server - return new Logger({ - context: { - app: 'mojaloop-sdk-test-api' - }, - space: this._conf.logIndent, - transports, - }); - } - - async _createCache() { - const transports = await Promise.all([Transports.consoleDir()]); - const logger = new Logger({ - context: { - app: 'mojaloop-sdk-inboundCache' - }, - space: this._conf.logIndent, - transports, - }); - - const cacheConfig = { - ...this._conf.cacheConfig, - logger - }; - - return new Cache(cacheConfig); } - _createServer() { - let server; - // If config specifies TLS, start an HTTPS server; otherwise HTTP - if (this._conf.tls.test.mutualTLS.enabled) { - const testHttpsOpts = { - ...this._conf.tls.test.creds, - requestCert: true, - rejectUnauthorized: true // no effect if requestCert is not true - }; - server = https.createServer(testHttpsOpts, this._api.callback()); - } else { - server = http.createServer(this._api.callback()); - } - - return server; + callback() { + return this._api.callback(); } +} - _createWsServer() { - const wss = new ws.Server({ noServer: true }); +class WsServer extends ws.Server { + constructor(logger, cache) { + super({ noServer: true }); + this._wsClients = new Map(); + this._logger = logger; + this._cache = cache; - wss.on('error', err => { - // Curtains down + this.on('error', err => { this._logger.push({ err }) .log('Unhandled websocket error occurred. Shutting down.'); process.exit(1); }); - wss.on('connection', (socket, req) => { + this.on('connection', (socket, req) => { const logger = this._logger.push({ url: req.url, ip: getWsIp(req), @@ -178,8 +75,21 @@ class TestServer { this._wsClients.delete(socket); }); }); + } - return wss; + async start() { + await this._cache.subscribe(this._cache.EVENT_SET, this._handleCacheKeySet.bind(this)); + } + + // Close the server then wait for all the client sockets to close + async stop() { + await new Promise(resolve => this.close(resolve)); + // If we don't wait for all clients to close before shutting down, the socket close + // handlers will be called after we return from this function, resulting in behaviour + // occurring after the server tells the user it has shutdown. + await Promise.all([...this._wsClients.keys()].map(socket => + new Promise(resolve => socket.on('close', resolve)) + )); } // Send the received notification to subscribers where appropriate. @@ -256,4 +166,50 @@ class TestServer { } } +class TestServer { + constructor({ port, logger, cache }) { + this._port = port; + this._logger = logger; + this._validator = new Validate(); + this._api = new TestApi(this._logger.push({ component: 'api' }), this._validator, cache); + this._server = http.createServer(this._api.callback()); + // TODO: why does this appear to need to be called after creating this._server (try reorder + // it then run the tests) + this._wsapi = new WsServer(this._logger.push({ component: 'websocket-server' }), cache); + } + + async start() { + if (this._server.listening) { + return; + } + const fileData = await fs.readFile(path.join(__dirname, 'api.yaml')); + await this._validator.initialise(yaml.load(fileData)); + + await this._wsapi.start(); + + this._server.on('upgrade', (req, socket, head) => { + this._wsapi.handleUpgrade(req, socket, head, (ws) => + this._wsapi.emit('connection', ws, req)); + }); + + await new Promise((resolve) => this._server.listen(this._port, resolve)); + + this._logger.log(`Serving test API on port ${this._port}`); + } + + async stop() { + if (this._wsapi) { + this._logger.log('Shutting down websocket server'); + this._wsapi.stop(); + this._wsapi = null; + } + if (this._server) { + this._logger.log('Shutting down http server'); + await new Promise(resolve => this._server.close(resolve)); + this._server = null; + } + this._logger.log('Test server shutdown complete'); + } +} + module.exports = TestServer; diff --git a/src/config.js b/src/config.js index c018674f2..272cbd942 100644 --- a/src/config.js +++ b/src/config.js @@ -21,18 +21,46 @@ function getFileContent(path) { return fs.readFileSync(path); } +/** + * Gets Resources versions from enviromental variable RESOURCES_VERSIONS + * should be string in format: "resouceOneName=1.0,resourceTwoName=1.1" + */ +function getVersionFromConfig (resourceString) { + const resourceVersionMap = {}; + resourceString + .split(',') + .forEach(e => e.split('=') + .reduce((p, c) => { + resourceVersionMap[p] = { + contentVersion: c, + acceptVersion: c.split('.')[0], + }; + })); + return resourceVersionMap; +} + +function parseResourceVersions (resourceString) { + if (!resourceString) return {}; + const resourceFormatRegex = /(([A-Za-z])\w*)=([0-9]+).([0-9]+)([^;:|],*)/g; + const noSpResources = resourceString.replace(/\s/g,''); + if (!resourceFormatRegex.test(noSpResources)) { + throw new Error('Resource versions format should be in format: "resouceOneName=1.0,resourceTwoName=1.1"'); + } + return getVersionFromConfig(noSpResources); +} + const env = from(process.env, { asFileContent: (path) => getFileContent(path), asFileListContent: (pathList) => pathList.split(',').map((path) => getFileContent(path)), asYamlConfig: (path) => yaml.load(getFileContent(path)), + asResourceVersions: (resourceString) => parseResourceVersions(resourceString), }); module.exports = { - inboundPort: env.get('INBOUND_LISTEN_PORT').default('4000').asPortNumber(), - outboundPort: env.get('OUTBOUND_LISTEN_PORT').default('4001').asPortNumber(), - testPort: env.get('TEST_LISTEN_PORT').default('4002').asPortNumber(), - tls: { - inbound: { + __parseResourceVersion: parseResourceVersions, + inbound: { + port: env.get('INBOUND_LISTEN_PORT').default('4000').asPortNumber(), + tls: { mutualTLS: { enabled: env.get('INBOUND_MUTUAL_TLS_ENABLED').default('false').asBool(), }, @@ -42,7 +70,10 @@ module.exports = { key: env.get('IN_SERVER_KEY_PATH').asFileContent(), }, }, - outbound: { + }, + outbound: { + port: env.get('OUTBOUND_LISTEN_PORT').default('4001').asPortNumber(), + tls: { mutualTLS: { enabled: env.get('OUTBOUND_MUTUAL_TLS_ENABLED').default('false').asBool(), }, @@ -52,21 +83,15 @@ module.exports = { key: env.get('OUT_CLIENT_KEY_PATH').asFileContent(), }, }, - test: { - mutualTLS: { - enabled: env.get('TEST_MUTUAL_TLS_ENABLED').default('false').asBool(), - }, - creds: { - ca: env.get('TEST_CA_CERT_PATH').asFileListContent(), - cert: env.get('TEST_CLIENT_CERT_PATH').asFileContent(), - key: env.get('TEST_CLIENT_KEY_PATH').asFileContent(), - }, - }, + }, + test: { + port: env.get('TEST_LISTEN_PORT').default('4002').asPortNumber(), }, peerEndpoint: env.get('PEER_ENDPOINT').required().asString(), alsEndpoint: env.get('ALS_ENDPOINT').asString(), quotesEndpoint: env.get('QUOTES_ENDPOINT').asString(), bulkQuotesEndpoint: env.get('BULK_QUOTES_ENDPOINT').asString(), + transactionRequestsEndpoint: env.get('TRANSACTION_REQUESTS_ENDPOINT').asString(), transfersEndpoint: env.get('TRANSFERS_ENDPOINT').asString(), bulkTransfersEndpoint: env.get('BULK_TRANSFERS_ENDPOINT').asString(), backendEndpoint: env.get('BACKEND_ENDPOINT').required().asString(), @@ -81,6 +106,7 @@ module.exports = { autoAcceptR2PBusinessQuotes: env.get('AUTO_ACCEPT_R2P_BUSINESS_QUOTES').default('false').asBool(), autoAcceptR2PDeviceQuotes: env.get('AUTO_ACCEPT_R2P_DEVICE_QUOTES').default('true').asBool(), autoAcceptR2PDeviceOTP: env.get('AUTO_ACCEPT_R2P_DEVICE_OTP').default('false').asBool(), + autoAcceptParticipantsPut: env.get('AUTO_ACCEPT_PARTICIPANTS_PUT').default('false').asBool(), /* TODO: high-risk transactions can require additional clearing check */ // enableClearingCheck: env.get('ENABLE_CLEARING_CHECK').default('false').asBool(), @@ -107,12 +133,15 @@ module.exports = { clientSecret: env.get('OAUTH_TOKEN_ENDPOINT_CLIENT_SECRET').asString(), listenPort: env.get('OAUTH_TOKEN_ENDPOINT_LISTEN_PORT').asPortNumber(), }, - wso2Auth: { - staticToken: env.get('WSO2_BEARER_TOKEN').asString(), - tokenEndpoint: env.get('OAUTH_TOKEN_ENDPOINT').asString(), - clientKey: env.get('OAUTH_CLIENT_KEY').asString(), - clientSecret: env.get('OAUTH_CLIENT_SECRET').asString(), - refreshSeconds: env.get('OAUTH_REFRESH_SECONDS').default('60').asIntPositive(), + wso2: { + auth: { + staticToken: env.get('WSO2_BEARER_TOKEN').asString(), + tokenEndpoint: env.get('OAUTH_TOKEN_ENDPOINT').asString(), + clientKey: env.get('OAUTH_CLIENT_KEY').asString(), + clientSecret: env.get('OAUTH_CLIENT_SECRET').asString(), + refreshSeconds: env.get('OAUTH_REFRESH_SECONDS').default('60').asIntPositive(), + }, + requestAuthFailureRetryTimes: env.get('WSO2_AUTH_FAILURE_REQUEST_RETRIES').default('0').asIntPositive(), }, rejectExpiredQuoteResponses: env.get('REJECT_EXPIRED_QUOTE_RESPONSES').default('false').asBool(), rejectTransfersOnExpiredQuotes: env.get('REJECT_TRANSFERS_ON_EXPIRED_QUOTES').default('false').asBool(), @@ -132,5 +161,7 @@ module.exports = { proxyConfig: env.get('PROXY_CONFIG_PATH').asYamlConfig(), reserveNotification: env.get('RESERVE_NOTIFICATION').default('false').asBool(), + // resourceVersions config should be string in format: "resouceOneName=1.0,resourceTwoName=1.1" + resourceVersions: env.get('RESOURCE_VERSIONS').default('').asResourceVersions(), enablePISPMode: env.get('ENABLE_PISP_MODE').default('false').asBool() }; diff --git a/src/index.js b/src/index.js index 842b135df..06ef71d54 100644 --- a/src/index.js +++ b/src/index.js @@ -11,7 +11,10 @@ 'use strict'; +const { hostname } = require('os'); const config = require('./config'); +const EventEmitter = require('events'); + const InboundServer = require('./InboundServer'); const OutboundServer = require('./OutboundServer'); const OAuthTestServer = require('./OAuthTestServer'); @@ -24,62 +27,70 @@ const OutboundServerMiddleware = require('./OutboundServer/middlewares.js'); const Router = require('@internal/router'); const Validate = require('@internal/validate'); const RandomPhrase = require('@internal/randomphrase'); -const Log = require('@internal/log'); const Cache = require('@internal/cache'); +const { Logger } = require('@mojaloop/sdk-standard-components'); /** * Class that creates and manages http servers that expose the scheme adapter APIs. */ -class Server { - constructor(conf) { +class Server extends EventEmitter { + constructor(conf, logger) { + super({ captureExceptions: true }); this.conf = conf; - this.inboundServer = null; - this.outboundServer = null; - this.oauthTestServer = null; - this.testServer = null; - } + this.logger = logger; + this.cache = new Cache({ + ...conf.cacheConfig, + logger: this.logger.push({ component: 'cache' }), + enableTestFeatures: conf.enableTestFeatures, + }); + + this.inboundServer = new InboundServer( + this.conf, + this.logger.push({ app: 'mojaloop-sdk-inbound-api' }), + this.cache + ); + this.inboundServer.on('error', (...args) => { + this.logger.push({ args }).log('Unhandled error in Inbound Server'); + this.emit('error', 'Unhandled error in Inbound Server'); + }); + + this.outboundServer = new OutboundServer( + this.conf, + this.logger.push({ app: 'mojaloop-sdk-outbound-api' }), + this.cache + ); + this.outboundServer.on('error', (...args) => { + this.logger.push({ args }).log('Unhandled error in Outbound Server'); + this.emit('error', 'Unhandled error in Outbound Server'); + }); - async start() { - this.inboundServer = new InboundServer(this.conf); - this.outboundServer = new OutboundServer(this.conf); this.oauthTestServer = new OAuthTestServer({ clientKey: this.conf.oauthTestServer.clientKey, clientSecret: this.conf.oauthTestServer.clientSecret, port: this.conf.oauthTestServer.listenPort, - logIndent: this.conf.logIndent, + logger: this.logger.push({ app: 'mojaloop-sdk-oauth-test-server' }), }); - this.testServer = new TestServer(this.conf); - - await Promise.all([ - this._startInboundServer(), - this._startOutboundServer(), - this._startOAuthTestServer(), - this._startTestServer(), - ]); - } - async _startTestServer() { - if (this.conf.enableTestFeatures) { - await this.testServer.setupApi(); - await this.testServer.start(); - } - } - - async _startInboundServer() { - await this.inboundServer.setupApi(); - await this.inboundServer.start(); + this.testServer = new TestServer({ + port: this.conf.test.port, + logger: this.logger.push({ app: 'mojaloop-sdk-test-api' }), + cache: this.cache, + }); } - async _startOutboundServer() { - await this.outboundServer.setupApi(); - await this.outboundServer.start(); - } + async start() { + await this.cache.connect(); - async _startOAuthTestServer() { - if (this.conf.oauthTestServer.enabled) { - await this.oauthTestServer.setupApi(); - await this.oauthTestServer.start(); - } + const startTestServer = this.conf.enableTestFeatures ? this.testServer.start() : null; + const startOauthTestServer = this.conf.oauthTestServer.enabled + ? this.oauthTestServer.start() + : null; + await Promise.all([ + this.inboundServer.start(), + this.outboundServer.start(), + startTestServer, + startOauthTestServer, + ]); } stop() { @@ -97,18 +108,29 @@ if(require.main === module) { (async () => { // this module is main i.e. we were started as a server; // not used in unit test or "require" scenarios - const svr = new Server(config); + const logger = new Logger.Logger({ + context: { + // If we're running from a Mojaloop helm chart deployment, we'll have a SIM_NAME + simulator: process.env['SIM_NAME'], + hostname: hostname(), + }, + stringify: Logger.buildStringify({ space: config.logIndent }), + }); + const svr = new Server(config, logger); + svr.on('error', (err) => { + logger.push({ err }).log('Unhandled server error'); + process.exit(1); + }); // handle SIGTERM to exit gracefully process.on('SIGTERM', async () => { - console.log('SIGTERM received. Shutting down APIs...'); - + logger.log('SIGTERM received. Shutting down APIs...'); await svr.stop(); process.exit(0); }); svr.start().catch(err => { - console.log(err); + logger.push({ err }).log('Error starting server'); process.exit(1); }); })(); @@ -118,12 +140,11 @@ if(require.main === module) { // export things we want to expose e.g. for unit tests and users who dont want to use the entire // scheme adapter as a service module.exports = { - Server: Server, - InboundServerMiddleware: InboundServerMiddleware, - OutboundServerMiddleware: OutboundServerMiddleware, - Router: Router, - Validate: Validate, - RandomPhrase: RandomPhrase, - Log: Log, - Cache: Cache + Cache, + InboundServerMiddleware, + OutboundServerMiddleware, + RandomPhrase, + Router, + Server, + Validate, }; diff --git a/src/lib/cache/cache.js b/src/lib/cache/cache.js index 2456b8d8b..55167a13e 100644 --- a/src/lib/cache/cache.js +++ b/src/lib/cache/cache.js @@ -13,6 +13,12 @@ const util = require('util'); const redis = require('redis'); +const CONN_ST = { + CONNECTED: 'CONNECTED', + CONNECTING: 'CONNECTING', + DISCONNECTED: 'DISCONNECTED', + DISCONNECTING: 'DISCONNECTING', +}; /** * A shared cache abstraction over a REDIS distributed key/value store @@ -30,6 +36,9 @@ class Cache { // a redis connection to handle get, set and publish operations this._client = null; + // connection/disconnection logic + this._connectionState = CONN_ST.DISCONNECTED; + // a redis connection to handle subscribe operations and published message routing // Note that REDIS docs suggest a client that is in SUBSCRIBE mode // should not have any other commands executed against it. @@ -51,15 +60,33 @@ class Cache { * See: https://redis.io/topics/pubsub */ async connect() { - if (this._connected) { - throw new Error('already connected'); + switch(this._connectionState) { + case CONN_ST.CONNECTED: + return; + case CONN_ST.CONNECTING: + await this._inProgressConnection; + return; + case CONN_ST.DISCONNECTED: + break; + case CONN_ST.DISCONNECTING: + // TODO: should this be an error? + // If we're disconnecting, we'll let that finish first + await this._inProgressDisconnection; + break; } - this._connected = true; - this._client = await this._getClient(); - this._subscriptionClient = await this._getClient(); + this._connectionState = CONN_ST.CONNECTING; + this._inProgressConnection = Promise.all([this._getClient(), this._getClient()]); + [this._client, this._subscriptionClient] = await this._inProgressConnection; // hook up our sub message handler this._subscriptionClient.on('message', this._onMessage.bind(this)); + + if (this._config.enableTestFeatures) { + this.setTestMode(true); + } + + this._inProgressConnection = null; + this._connectionState = CONN_ST.CONNECTED; } /** @@ -81,14 +108,30 @@ class Cache { } async disconnect() { - if (!this._connected) { - return; + switch(this._connectionState) { + case CONN_ST.CONNECTED: + break; + case CONN_ST.CONNECTING: + // TODO: should this be an error? + // If we're connecting, we'll let that finish first + await this._inProgressConnection; + break; + case CONN_ST.DISCONNECTED: + return; + case CONN_ST.DISCONNECTING: + await this._inProgressDisconnection; + return; } - await Promise.all([ + this._connectionState = CONN_ST.DISCONNECTING; + this._inProgressDisconnection = Promise.all([ new Promise(resolve => this._client.quit(resolve)), new Promise(resolve => this._subscriptionClient.quit(resolve)) ]); - this._connected = false; + this._client = null; + this._subscriptionClient = null; + await this._inProgressDisconnection; + this._inProgressDisconnection = null; + this._connectionState = CONN_ST.DISCONNECTED; } diff --git a/src/lib/check/index.js b/src/lib/check/index.js new file mode 100644 index 000000000..9bc053eb7 --- /dev/null +++ b/src/lib/check/index.js @@ -0,0 +1,25 @@ +/************************************************************************** + * (C) Copyright ModusBox Inc. 2020 - All rights reserved. * + * * + * This file is made available under the terms of the license agreement * + * specified in the corresponding source code repository. * + * * + * ORIGINAL AUTHOR: * + * Matt Kingston - matt.kingston@modusbox.com * + **************************************************************************/ + +// This module maps all methods on Node assert to non-throwing "check" functions which return true +// if the assertion succeeded and false otherwise + +const assert = require('assert').strict; + +module.exports = Object.fromEntries( + Object.entries(assert).map(([k, f]) => [k, (...args) => { + try { + f.bind(assert)(...args); + return true; + } catch (err) { + return false; + } + }]) +); diff --git a/src/lib/check/package.json b/src/lib/check/package.json new file mode 100644 index 000000000..e6ef9809e --- /dev/null +++ b/src/lib/check/package.json @@ -0,0 +1,12 @@ +{ + "name": "check", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/src/lib/log/log.js b/src/lib/log/log.js deleted file mode 100644 index 679b5a5cb..000000000 --- a/src/lib/log/log.js +++ /dev/null @@ -1,216 +0,0 @@ -/************************************************************************** - * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * - * * - * This file is made available under the terms of the license agreement * - * specified in the corresponding source code repository. * - * * - * ORIGINAL AUTHOR: * - * Matt Kingston - matt.kingston@modusbox.com * - **************************************************************************/ - -'use strict'; - - -// An immutable structured logger. It uses JSON.stringify to -// stringify any arguments. -// TODO: either make this callable, or remove this text. See https://stackoverflow.com/questions/36871299/how-to-extend-function-with-es6-classes -// It is callable, such that: -// (new require('./log'))('stuff I want logged'); -// will print: -// { "msg": "stuff I want logged" } -// -// JSON.stringify blocks the event loop. At the time of writing, performance/responsiveness were -// not requirements of this module. If this is later required, see the discussion here for -// solutions: https://nodejs.org/en/docs/guides/dont-block-the-event-loop/. This may necessitate -// either a print queue, or a print sequence number to help identify print order. This could be -// optional in the constructor options. - -// This logger could be considered immutable for the following two reasons. However, it could -// retain that property and simply return a new logger from a 'pop' or 'replace' method. -// 1) At the time of writing, this class does not implement any mechanism to remove any logging -// context. This was a conscious decision to enable better reasoning about logging. "This logger is -// derived from that logger, therefore the context must be a non-strict superset of the context -// of the parent logger". However, this is something of an experiment, and at some time in the -// future may be considered an impediment, or redundant, and the aforementioned mechanism (e.g. -// a pop method) may be added. -// 2) No 'replace' method (or method for overwriting logged context) has been implemented. This is -// for the same reason no 'pop' method has been implemented. - -// TODO: -// - support log levels? just support conditional logging? (i.e. if the .level property is "in -// {x,y,z}" then log) -// - just call the logger to add a message property and log that- pass all arguments to util.format -// - support logging to a single line -// - support 'verbose', 'debug', 'warn', 'error', 'trace', 'info', 'fatal' methods? -// - support env var config? - -// TODO: -// Is it possible to pretty-print log messages or strings containing new-line characters? For -// example, instead of printing the '\n' characters in a stack-trace, actually printing the -// new-line characters. Is that possible and/or worthwhile? - -const util = require('util'); - -const contextSym = Symbol('Logger context symbol'); - - -/** - * Returns a function that removes circular references and some custom - * interpretations of certain types of objects - * - * @returns {function} - */ -const getReplacer = () => { - const seen = new WeakSet(); - return (key, value) => { - if (typeof value === 'object' && value !== null) { - if (seen.has(value)) { - return '[Circular Reference]'; - } - seen.add(value); - } - - return replaceOutput(key, value); - }; -}; - -const replaceOutput = (key, value) => { - if (value instanceof Error) { - return Object.getOwnPropertyNames(value).reduce((acc, key) => ({ ...acc, [key]: value[key] }), {}); - } - if (value instanceof RegExp) { - return value.toString(); - } - if (value instanceof Function) { - return `[Function: ${value.name || 'anonymous'}]`; - } - return value; -}; - - -class Logger { - // space - // String | Number - // The default formatting to be supplied to the JSON.stringify method. Examples include the - // string '\t' to indent with a tab and the number 4 to indent with four spaces. The default, - // undefined, will not break lines. - // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Parameters - // printTimestamp - // Boolean - // Whether to print a timestamp. - // timestampFmt - // Function - // A function that accepts a Date object and produces a timestamp string. - // transports - // Array of functions - // Each function will be supplied with arguments (String msg, Date timestamp) for each log - // event. - // context - // Object - // Context data to preload in the logger. Example: { path: '/users', method: 'GET' } - // This logger and all loggers derived from it (with the push method) will print this context - // If any reserved keys exist in the new object, an error will be thrown. - constructor({ - context = {}, - space, - printTimestamp = true, - timestampFmt = ts => ts.toISOString(), - transports = [] - } = {}) { - this.opts = {}; - this.configure({ space, printTimestamp, timestampFmt }); - if (this.opts.printTimestamp && 'timestamp' in context) { - throw new Error('\'timestamp\' is a reserved logger key when providing \'timestamp: true\' to the constructor'); - } - if ('msg' in context) { - throw new Error('\'msg\' is a reserved logger key'); - } - this.opts.transports = transports; - this[contextSym] = context; - } - - // Update logger configuration. - // opts - // Object. May contain any of .space, .printTimestamp, .timestampFmt - // See constructor comment for details - configure(opts) { - // TODO: check whether printTimestamp has gone from false to true, and whether the - // timestamp key exists in our context - // TODO: should we check whether a timestamp format function has been provided, but - // printtimestamp has been set to false? - this.opts = { ...this.opts, ...opts }; - } - - // space - // String | Number - // The default formatting to be supplied to the JSON.stringify method. Examples include the - // string '\t' to indent with a tab and the number 4 to indent with four spaces. The default, - // undefined, will not break lines. - setSpace(space) { - this.space = space; - } - - // Create a new logger with the same context as the current logger, and additionally any - // supplied context. - // context - // An object to log. Example: { path: '/users', method: 'GET' } - // If a key in this object already exists in this logger, an error will be thrown. - push(context) { - if (!context) { - return new Logger({ ...this.opts, context: this[contextSym] }); - } - // Check none of the new context replaces any of the old context - if (-1 !== Object.keys(context).findIndex(k => Object.keys(this[contextSym]).findIndex(l => l === k) !== -1)) { - throw new Error('Key already exists in logger'); - } - return new Logger({ ...this.opts, context: { ...this[contextSym], ...context } }); - } - - // Log to transports. - // args - // Any type is acceptable. All arguments will be passed to util.format, then printed as the - // 'msg' property of the logged item. - async log(...args) { - // NOTE: if printing large strings, JSON.stringify will block the event loop. This, and - // solutions, are discussed here: - // https://nodejs.org/en/docs/guides/dont-block-the-event-loop/. - // At the time of writing, this was considered unlikely to be a problem, as this - // implementation did not have any performance requirements - const msg = args.length > 0 ? util.format(...args) : undefined; - const ts = new Date(); - let output; - if (this.opts.printTimestamp) { - output = JSON.stringify({ ...this[contextSym], msg, timestamp: this.opts.timestampFmt(ts) }, getReplacer(), this.opts.space); - } else { - output = JSON.stringify({ ...this[contextSym], msg }, getReplacer(), this.opts.space); - } - await Promise.all(this.opts.transports.map(t => t(output, ts))); - } - - async error(...args) { - await this.log({ - LOG_LEVEL: 'ERROR', - ...args, - }); - } - - async info(...args) { - await this.log({ - LOG_LEVEL: 'INFO', - ...args, - }); - } - - async debug(...args) { - await this.log({ - LOG_LEVEL: 'DEBUG', - ...args, - }); - } -} - - -module.exports = { - Logger, - Transports: require('./transports') -}; diff --git a/src/lib/log/package.json b/src/lib/log/package.json deleted file mode 100644 index 9ea30a351..000000000 --- a/src/lib/log/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@internal/log", - "version": "0.0.1", - "description": "An immutable strutured logger", - "main": "log.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "Matt Kingston, ModusBox Inc", - "license": "Apache-2.0", - "licenses": [ - { - "type": "Apache-2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0" - } - ], - "dependencies": { - "sqlite": "^3.0.1" - } -} diff --git a/src/lib/log/transports.js b/src/lib/log/transports.js deleted file mode 100644 index d1b5fc253..000000000 --- a/src/lib/log/transports.js +++ /dev/null @@ -1,61 +0,0 @@ -/************************************************************************** - * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * - * * - * This file is made available under the terms of the license agreement * - * specified in the corresponding source code repository. * - * * - * ORIGINAL AUTHOR: * - * Matt Kingston - matt.kingston@modusbox.com * - **************************************************************************/ - -'use strict'; - - -const fs = require('fs'); -// TODO: consider: https://github.com/JoshuaWise/better-sqlite3 -const sqlite = require('sqlite'); - -const consoleDir = () => msg => { - console.dir(JSON.parse(msg), { depth: Infinity, colors: true }); -}; - -const stdout = () => msg => { - process.stdout.write(msg); - process.stdout.write('\n'); -}; - -const file = path => { - // TODO: check this isn't in object mode. See here for more: - // https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback - const stream = fs.createWriteStream(path, { flags: 'a' }); - // TODO: when the filesystem fills up? - return async msg => new Promise(res => { - if (!stream.write(msg)) { - stream.once('drain', res); - } else { - res(); - } - }); -}; - -const sqliteTransport = async path => { - // TODO: enable db object cache? https://github.com/mapbox/node-sqlite3/wiki/Caching - const db = await sqlite.open(path); - await db.run('CREATE TABLE IF NOT EXISTS log(timestamp TEXT, entry TEXT)'); - await db.run('CREATE INDEX IF NOT EXISTS log_timestamp_index ON log(timestamp)'); - // TODO: when the filesystem fills up? - // - set a maximum table size? Discard earlier entries when full? - return async ($msg, timestamp) => { - const $ts = timestamp.toISOString(); - await db.run('INSERT INTO log(timestamp, entry) VALUES ($ts, json($msg))', { $ts, $msg }); - return; - }; -}; - - -module.exports = { - stdout, - sqlite: sqliteTransport, - file, - consoleDir, -}; diff --git a/src/lib/model/AccountsModel.js b/src/lib/model/AccountsModel.js index 47f779228..33c55bfd2 100644 --- a/src/lib/model/AccountsModel.js +++ b/src/lib/model/AccountsModel.js @@ -39,7 +39,7 @@ class AccountsModel { tls: config.tls, jwsSign: config.jwsSign, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }); } diff --git a/src/lib/model/InboundThirdpartyTransactionModel.js b/src/lib/model/InboundThirdpartyTransactionModel.js index 69e9ab59c..ab431e959 100644 --- a/src/lib/model/InboundThirdpartyTransactionModel.js +++ b/src/lib/model/InboundThirdpartyTransactionModel.js @@ -36,11 +36,13 @@ class InboundThirdpartyTransactionModel { alsEndpoint: config.alsEndpoint, quotesEndpoint: config.quotesEndpoint, transfersEndpoint: config.transfersEndpoint, + transactionRequestsEndpoint: config.transactionRequestsEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, + jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }); this._backendRequests = new BackendRequests({ @@ -51,7 +53,7 @@ class InboundThirdpartyTransactionModel { } /** - * Queries the backend API to get the authorization details and makes a + * Queries the backend API to get the authorization details and makes a * callback to the originator with the result */ async postAuthorizations(authorizationsReq, sourceFspId) { @@ -91,7 +93,7 @@ class InboundThirdpartyTransactionModel { // do nothing this._logger.push({ ex }).log('Error parsing error message body as JSON'); } - + } else if(e.res.data) { mojaloopErrorCode = Errors.MojaloopApiErrorCodeFromCode(`${e.res.data.statusCode}`); } diff --git a/src/lib/model/InboundTransfersModel.js b/src/lib/model/InboundTransfersModel.js index 79004f11a..3e471ebc4 100644 --- a/src/lib/model/InboundTransfersModel.js +++ b/src/lib/model/InboundTransfersModel.js @@ -22,7 +22,6 @@ const { } = require('@mojaloop/sdk-standard-components'); const shared = require('@internal/shared'); - /** * Models the operations required for performing inbound transfers */ @@ -43,11 +42,14 @@ class InboundTransfersModel { quotesEndpoint: config.quotesEndpoint, transfersEndpoint: config.transfersEndpoint, bulkTransfersEndpoint: config.bulkTransfersEndpoint, + transactionRequestsEndpoint: config.transactionRequestsEndpoint, + bulkQuotesEndpoint: config.bulkQuotesEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.inbound.tls, jwsSign: config.jwsSign, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, + resourceVersions: config.resourceVersions }); this._backendRequests = new BackendRequests({ @@ -59,7 +61,8 @@ class InboundTransfersModel { this._checkIlp = config.checkIlp; this._ilp = new Ilp({ - secret: config.ilpSecret + secret: config.ilpSecret, + logger: this._logger, }); } @@ -190,6 +193,9 @@ class InboundTransfersModel { fulfilment: fulfilment }); + // now store the quoteRespnse data against the quoteId in our cache to be sent as a response to GET /quotes/{ID} + await this._cache.set(`quoteResponse_${quoteRequest.quoteId}`, mojaloopResponse); + // make a callback to the source fsp with the quote response return this._mojaloopRequests.putQuotes(quoteRequest.quoteId, mojaloopResponse, sourceFspId); } @@ -202,6 +208,35 @@ class InboundTransfersModel { } } + /** + * This is executed as when GET /quotes/{ID} request is made to get the response of a previous POST /quotes request. + * Gets the quoteResponse from the cache and makes a callback to the originator with result + */ + async getQuoteRequest(quoteId, sourceFspId) { + try { + // Get the quoteRespnse data for the quoteId from the cache to be sent as a response to GET /quotes/{ID} + const quoteResponse = await this._cache.get(`quoteResponse_${quoteId}`); + + // If no quoteResponse is found in the cache, make an error callback to the source fsp + if (!quoteResponse) { + const err = new Error('Quote Id not found'); + const mojaloopError = await this._handleError(err, Errors.MojaloopApiErrorCodes.QUOTE_ID_NOT_FOUND); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return await this._mojaloopRequests.putQuotesError(quoteId, + mojaloopError, sourceFspId); + } + // Make a PUT /quotes/{ID} callback to the source fsp with the quote response + return this._mojaloopRequests.putQuotes(quoteId, quoteResponse, sourceFspId); + } + catch(err) { + this._logger.push({ err }).log('Error in getQuoteRequest'); + const mojaloopError = await this._handleError(err); + this._logger.push({ mojaloopError }).log(`Sending error response to ${sourceFspId}`); + return await this._mojaloopRequests.putQuotesError(quoteId, + mojaloopError, sourceFspId); + } + } + /** * Asks the backend for a response to an incoming transactoin request and makes a callback to the originator with * the result @@ -411,7 +446,7 @@ class InboundTransfersModel { (quoteResult) => quoteResult.quoteId === quote.quoteId ); const quoteResponse = { - amount: mojaloopIndividualQuote.transferAmount, + transferAmount: mojaloopIndividualQuote.transferAmount, note: mojaloopIndividualQuote.note || '', }; const { fulfilment, ilpPacket, condition } = this._ilp.getQuoteResponseIlp( @@ -667,9 +702,19 @@ class InboundTransfersModel { } } - async _handleError(err) { - let mojaloopErrorCode = Errors.MojaloopApiErrorCodes.INTERNAL_SERVER_ERROR; + /** + * Forwards Switch notification for fulfiled transfer to the DFSP backend, when acting as a payee + */ + async sendNotificationToPayee(body, transferId) { + try { + const res = await this._backendRequests.putTransfersNotification(body, transferId); + return res; + } catch (err) { + this._logger.push({ err }).log('Error in sendNotificationToPayee'); + } + } + async _handleError(err, mojaloopErrorCode = Errors.MojaloopApiErrorCodes.INTERNAL_SERVER_ERROR) { if(err instanceof HTTPResponseError) { const e = err.getData(); if(e.res && e.res.data) { diff --git a/src/lib/model/OutboundAuthorizationsModel.js b/src/lib/model/OutboundAuthorizationsModel.js index 9eea29a26..fd0313d41 100644 --- a/src/lib/model/OutboundAuthorizationsModel.js +++ b/src/lib/model/OutboundAuthorizationsModel.js @@ -21,7 +21,7 @@ const specStateMachine = { transitions: [ { name: 'init', from: 'none', to: 'start' }, { name: 'requestAuthorization', from: 'start', to: 'succeeded' }, - { name: 'error', from: '*', to: 'errored' }, + { name: 'error', from: '*', to: 'errored' }, ], methods: { // workflow methods @@ -93,7 +93,7 @@ const mapCurrentState = { function getResponse() { const { data, logger } = this.context; let resp = { ...data }; - + // project some of our internal state into a more useful // representation to return to the SDK API consumer resp.currentState = mapCurrentState[data.currentState]; @@ -120,7 +120,7 @@ function notificationChannel(id) { /** * Requests Authorization * Starts the authorization process by sending a POST /authorizations request to switch; - * than await for a notification on PUT /authorizations/ from the cache that the Authorization has been resolved + * than await for a notification on PUT /authorizations/ from the cache that the Authorization has been resolved */ async function onRequestAuthorization() { const { data, cache, logger } = this.context; @@ -132,10 +132,10 @@ async function onRequestAuthorization() { return new Promise( async(resolve, reject) => { try { - // in InboundServer/handlers is implemented putAuthorizationsById handler + // in InboundServer/handlers is implemented putAuthorizationsById handler // where this event is fired but only if env ENABLE_PISP_MODE=true subId = await cache.subscribe(channel, async (channel, message, sid) => { - try { + try { const parsed = JSON.parse(message); this.context.data = { ...parsed, @@ -143,20 +143,20 @@ async function onRequestAuthorization() { }; resolve(); } catch(err) { - reject(err); + reject(err); } finally { if(sid) { cache.unsubscribe(channel, sid); } } }); - + // POST /authorization request to the switch const postRequest = buildPostAuthorizationsRequest(data, config); const res = await requests.postAuthorizations(postRequest, data.toParticipantId); - + logger.push({ res }).log('Authorizations request sent to peer'); - + } catch(error) { logger.push(error).error('Authorization request error'); if(subId) { @@ -183,13 +183,13 @@ function buildPostAuthorizationsRequest(data/** , config */) { /** * injects the config into state machine data * so it will be accessible to on transition notification handlers via `this.handlersContext` - * + * * @param {Object} config - config to be injected into state machine data * @param {Object} specStateMachine - specState machine to be altered * @returns {Object} - the altered specStateMachine */ function injectHandlersContext(config, specStateMachine) { - return { + return { ...specStateMachine, data: { handlersContext: { @@ -198,12 +198,15 @@ function injectHandlersContext(config, specStateMachine) { logger: config.logger, peerEndpoint: config.peerEndpoint, alsEndpoint: config.alsEndpoint, + quotesEndpoint: config.quotesEndpoint, + transfersEndpoint: config.transfersEndpoint, + transactionRequestsEndpoint: config.transactionRequestsEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }) } } @@ -213,7 +216,7 @@ function injectHandlersContext(config, specStateMachine) { /** * creates a new instance of state machine specified in specStateMachine ^ - * + * * @param {Object} data - payload data * @param {String} key - the cache key where state machine will store the payload data after each transition * @param {Object} config - the additional configuration for transition handlers @@ -243,8 +246,8 @@ module.exports = { create, loadFromCache, notificationChannel, - + // exports for testing purposes - mapCurrentState, + mapCurrentState, buildPostAuthorizationsRequest -}; \ No newline at end of file +}; diff --git a/src/lib/model/OutboundBulkQuotesModel.js b/src/lib/model/OutboundBulkQuotesModel.js index 2adf87408..cc4fbd195 100644 --- a/src/lib/model/OutboundBulkQuotesModel.js +++ b/src/lib/model/OutboundBulkQuotesModel.js @@ -40,10 +40,10 @@ class OutboundBulkQuotesModel { peerEndpoint: config.peerEndpoint, bulkQuotesEndpoint: config.bulkQuotesEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }); } diff --git a/src/lib/model/OutboundBulkTransfersModel.js b/src/lib/model/OutboundBulkTransfersModel.js index f7c1220c8..526dc0453 100644 --- a/src/lib/model/OutboundBulkTransfersModel.js +++ b/src/lib/model/OutboundBulkTransfersModel.js @@ -42,7 +42,7 @@ class OutboundBulkTransfersModel { jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }); } @@ -204,7 +204,7 @@ class OutboundBulkTransfersModel { // now we have a timeout handler and a cache subscriber hooked up we can fire off // a POST /bulkTransfers request to the switch try { - const res = await this._requests.postBulkTransfers(bulkTransferPrepare, this.data.payeeFsp.fspId); + const res = await this._requests.postBulkTransfers(bulkTransferPrepare, this.data.from.fspId); this._logger.push({ res }).log('Bulk transfer request sent to peer'); } catch (err) { @@ -231,7 +231,7 @@ class OutboundBulkTransfersModel { bulkTransferId: this.data.bulkTransferId, bulkQuoteId: this.data.bulkQuoteId, payerFsp: this._dfspId, - payeeFsp: this.data.payeeFsp.fspId, + payeeFsp: this.data.individualTransfers[0].to.fspId, expiration: this._getExpirationTimestamp() }; @@ -243,6 +243,8 @@ class OutboundBulkTransfersModel { } bulkTransferRequest.individualTransfers = this.data.individualTransfers.map((individualTransfer) => { + if (bulkTransferRequest.payeeFsp !== individualTransfer.to.fspId) throw new BackendError('payee fsps are not the same into the whole bulk', 500); + const transferId = individualTransfer.transferId || uuid(); const transferPrepare = { @@ -261,6 +263,7 @@ class OutboundBulkTransfersModel { }; } + return transferPrepare; }); diff --git a/src/lib/model/OutboundRequestToPayModel.js b/src/lib/model/OutboundRequestToPayModel.js index a14fd8a57..6f88e4b38 100644 --- a/src/lib/model/OutboundRequestToPayModel.js +++ b/src/lib/model/OutboundRequestToPayModel.js @@ -32,7 +32,7 @@ class OutboundRequestToPayModel { this._dfspId = config.dfspId; this._expirySeconds = config.expirySeconds; this._autoAcceptParty = config.autoAcceptParty; - + this._requests = new MojaloopRequests({ logger: this._logger, peerEndpoint: config.peerEndpoint, @@ -42,7 +42,7 @@ class OutboundRequestToPayModel { jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }); } @@ -510,4 +510,4 @@ class OutboundRequestToPayModel { } } -module.exports = OutboundRequestToPayModel; \ No newline at end of file +module.exports = OutboundRequestToPayModel; diff --git a/src/lib/model/OutboundRequestToPayTransferModel.js b/src/lib/model/OutboundRequestToPayTransferModel.js index e9320dc47..e61cd21b6 100644 --- a/src/lib/model/OutboundRequestToPayTransferModel.js +++ b/src/lib/model/OutboundRequestToPayTransferModel.js @@ -58,7 +58,7 @@ class OutboundRequestToPayTransferModel { jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }); this._backendRequests = new BackendRequests({ @@ -68,7 +68,8 @@ class OutboundRequestToPayTransferModel { }); this._ilp = new Ilp({ - secret: config.ilpSecret + secret: config.ilpSecret, + logger: this._logger, }); this._enablePISPMode = config.enablePISPMode; diff --git a/src/lib/model/OutboundThirdpartyTransactionModel.js b/src/lib/model/OutboundThirdpartyTransactionModel.js index aa2eea6ce..71b6a49e9 100644 --- a/src/lib/model/OutboundThirdpartyTransactionModel.js +++ b/src/lib/model/OutboundThirdpartyTransactionModel.js @@ -73,12 +73,15 @@ function injectHandlersContext(config, specStateMachine) { logger: config.logger, peerEndpoint: config.peerEndpoint, alsEndpoint: config.alsEndpoint, + quotesEndpoint: config.quotesEndpoint, + transfersEndpoint: config.transfersEndpoint, + transactionRequestsEndpoint: config.transactionRequestsEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }) } } diff --git a/src/lib/model/OutboundTransfersModel.js b/src/lib/model/OutboundTransfersModel.js index 694601066..2cd7d6db0 100644 --- a/src/lib/model/OutboundTransfersModel.js +++ b/src/lib/model/OutboundTransfersModel.js @@ -48,16 +48,18 @@ class OutboundTransfersModel { alsEndpoint: config.alsEndpoint, quotesEndpoint: config.quotesEndpoint, transfersEndpoint: config.transfersEndpoint, + transactionRequestsEndpoint: config.transactionRequestsEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }); this._ilp = new Ilp({ - secret: config.ilpSecret + secret: config.ilpSecret, + logger: this._logger, }); } @@ -118,6 +120,10 @@ class OutboundTransfersModel { this.data.currentState = 'start'; } + if(!this.data.hasOwnProperty('initiatedTimestamp')) { + this.data.initiatedTimestamp = new Date().toISOString(); + } + this._initStateMachine(this.data.currentState); } @@ -779,6 +785,7 @@ class OutboundTransfersModel { case 'errored': // stopped in errored state + await this._save(); this._logger.log('State machine in errored state'); return; } @@ -801,6 +808,7 @@ class OutboundTransfersModel { // avoid circular ref between transferState.lastError and err err.transferState = JSON.parse(JSON.stringify(this.getResponse())); + await this._save(); } throw err; } diff --git a/src/lib/model/PartiesModel.js b/src/lib/model/PartiesModel.js index 1f0885c00..8fdedebde 100644 --- a/src/lib/model/PartiesModel.js +++ b/src/lib/model/PartiesModel.js @@ -56,7 +56,7 @@ async function run(type, id, subId) { // don't await to finish the save this.saveToCache(); logger.log(`Party information requested for /${type}/${id}/${subId}, currentState: ${data.currentState}`); - + // eslint-disable-next-line no-fallthrough case 'succeeded': // all steps complete so return @@ -101,7 +101,7 @@ const mapCurrentState = { function getResponse() { const { data, logger } = this.context; let resp = { ...data }; - + // project some of our internal state into a more useful // representation to return to the SDK API consumer resp.currentState = mapCurrentState[data.currentState]; @@ -132,13 +132,13 @@ async function onRequestPartiesInformation(fsm, type, id, subId) { // eslint-disable-next-line no-async-promise-executor return new Promise( async(resolve, reject) => { try { - // in InboundServer/handlers is implemented putPartiesById handler + // in InboundServer/handlers is implemented putPartiesById handler sid = await cache.subscribe(channel, async (channel, message, sid) => { // unsubscribe first cache.unsubscribe(channel, sid); // protect against malformed JSON message - try { + try { const parsed = JSON.parse(message); this.context.data = { ...parsed, @@ -146,15 +146,15 @@ async function onRequestPartiesInformation(fsm, type, id, subId) { }; resolve(); } catch(err) { - reject(err); + reject(err); } }); - + // GET /parties request to the switch const res = await requests.getParties(type, id, subId); - + logger.push({ res }).log(' RequestPartiesInformation sent to peer'); - + } catch(error) { logger.push(error).error('RequestPartiesInformation error'); cache.unsubscribe(channel, sid); @@ -198,7 +198,7 @@ function generateKey(type, id, subId) { * @returns {Object} - the altered specStateMachine */ function injectHandlersContext(config) { - return { + return { ...specStateMachine, data: { handlersContext: { @@ -207,12 +207,15 @@ function injectHandlersContext(config) { logger: config.logger, peerEndpoint: config.peerEndpoint, alsEndpoint: config.alsEndpoint, + quotesEndpoint: config.quotesEndpoint, + transfersEndpoint: config.transfersEndpoint, + transactionRequestsEndpoint: config.transactionRequestsEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, - wso2Auth: config.wso2Auth + wso2: config.wso2, }) } } diff --git a/src/lib/model/lib/requests/backendRequests.js b/src/lib/model/lib/requests/backendRequests.js index fcec321cd..70a835bcd 100644 --- a/src/lib/model/lib/requests/backendRequests.js +++ b/src/lib/model/lib/requests/backendRequests.js @@ -149,6 +149,17 @@ class BackendRequests { return this._get(url); } + /** + * Executes a PUT /transfers/{ID} request to forward notification for success + * + * @returns {object} - JSON response body if one was received + */ + + async putTransfersNotification(notifcation, transferId) { + const url = `transfers/${transferId}`; + return this._put(url, notifcation); + } + /** * Utility function for building outgoing request headers as required by the mojaloop api spec * @@ -189,7 +200,7 @@ class BackendRequests { method: 'PUT', uri: buildUrl(this.backendEndpoint, url), headers: this._buildHeaders(), - body: JSON.stringify(body), + body: JSON.stringify(body) }; try { diff --git a/src/lib/model/lib/shared/index.js b/src/lib/model/lib/shared/index.js index 939b1a86b..a25e4d852 100644 --- a/src/lib/model/lib/shared/index.js +++ b/src/lib/model/lib/shared/index.js @@ -347,7 +347,7 @@ const mojaloopBulkQuotesRequestToInternal = (external) => { * @returns {object} */ const internalBulkQuotesResponseToMojaloop = (internal) => { - const individualQuoteResults = internal.individualQuotes.map((quote) => { + const individualQuoteResults = internal.individualQuoteResults.map((quote) => { const externalQuote = { quoteId: quote.quoteId, transferAmount: { diff --git a/src/lib/randomphrase/randomphrase.js b/src/lib/randomphrase/randomphrase.js index ab8eb8f04..58ad1c305 100644 --- a/src/lib/randomphrase/randomphrase.js +++ b/src/lib/randomphrase/randomphrase.js @@ -5,12 +5,11 @@ * specified in the corresponding source code repository. * * * * ORIGINAL AUTHOR: * - * James Bush - james.bush@modusbox.com * + * Matt Kingston - matt.kingston@modusbox.com * **************************************************************************/ 'use strict'; - const words = require('./words.json'); const randomEl = arr => arr[Math.floor(Math.random() * arr.length)]; diff --git a/src/lib/validate/index.js b/src/lib/validate/index.js index 6e1a54a1c..d8db67ac3 100644 --- a/src/lib/validate/index.js +++ b/src/lib/validate/index.js @@ -194,7 +194,7 @@ class Validator { throw err; } - err = new Error(util.format('Request failed validation', validationResult)); + err = new Error(util.format('Request failed validation', validationResult)); Object.assign(err, firstError); throw err; } diff --git a/src/package-lock.json b/src/package-lock.json new file mode 100644 index 000000000..4c7733ea6 --- /dev/null +++ b/src/package-lock.json @@ -0,0 +1,8472 @@ +{ + "name": "@mojaloop/sdk-scheme-adapter", + "version": "11.10.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/core": { + "version": "7.12.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.10.tgz", + "integrity": "sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.10", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.10", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.10", + "@babel/types": "^7.12.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "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 + } + } + }, + "@babel/generator": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.12.11.tgz", + "integrity": "sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.11", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + }, + "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 + } + } + }, + "@babel/helper-function-name": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz", + "integrity": "sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.12.10", + "@babel/template": "^7.12.7", + "@babel/types": "^7.12.11" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.12.10", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz", + "integrity": "sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag==", + "dev": true, + "requires": { + "@babel/types": "^7.12.10" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.12.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz", + "integrity": "sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw==", + "dev": true, + "requires": { + "@babel/types": "^7.12.7" + } + }, + "@babel/helper-module-imports": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz", + "integrity": "sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.5" + } + }, + "@babel/helper-module-transforms": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz", + "integrity": "sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.12.1", + "@babel/helper-replace-supers": "^7.12.1", + "@babel/helper-simple-access": "^7.12.1", + "@babel/helper-split-export-declaration": "^7.11.0", + "@babel/helper-validator-identifier": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.12.1", + "@babel/types": "^7.12.1", + "lodash": "^4.17.19" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.12.10", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz", + "integrity": "sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ==", + "dev": true, + "requires": { + "@babel/types": "^7.12.10" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz", + "integrity": "sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.12.7", + "@babel/helper-optimise-call-expression": "^7.12.10", + "@babel/traverse": "^7.12.10", + "@babel/types": "^7.12.11" + } + }, + "@babel/helper-simple-access": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz", + "integrity": "sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.1" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz", + "integrity": "sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g==", + "dev": true, + "requires": { + "@babel/types": "^7.12.11" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", + "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==", + "dev": true + }, + "@babel/helpers": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.5.tgz", + "integrity": "sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA==", + "dev": true, + "requires": { + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.12.5", + "@babel/types": "^7.12.5" + } + }, + "@babel/highlight": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", + "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "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 + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "@babel/parser": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.11.tgz", + "integrity": "sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg==", + "dev": true + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz", + "integrity": "sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz", + "integrity": "sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/template": { + "version": "7.12.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.12.7.tgz", + "integrity": "sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/parser": "^7.12.7", + "@babel/types": "^7.12.7" + } + }, + "@babel/traverse": { + "version": "7.12.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.12.tgz", + "integrity": "sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.11", + "@babel/generator": "^7.12.11", + "@babel/helper-function-name": "^7.12.11", + "@babel/helper-split-export-declaration": "^7.12.11", + "@babel/parser": "^7.12.11", + "@babel/types": "^7.12.12", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.19" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.12.12", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.12.tgz", + "integrity": "sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "@cnakazawa/watch": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", + "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==", + "dev": true, + "requires": { + "exec-sh": "^0.3.2", + "minimist": "^1.2.0" + } + }, + "@eslint/eslintrc": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.2.2.tgz", + "integrity": "sha512-EfB5OHNYp1F4px/LI/FEnGylop7nOqkQ1LRzCM0KccA2U8tvV8w01KBv37LbO7nW4H+YhKyo2LcJhRwjjV17QQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@internal/cache": { + "version": "file:lib/cache", + "requires": { + "redis": "^2.8.0" + } + }, + "@internal/check": { + "version": "file:lib/check" + }, + "@internal/model": { + "version": "file:lib/model", + "requires": { + "@internal/requests": "file:lib/model/lib/requests", + "@internal/shared": "file:lib/model/lib/shared", + "javascript-state-machine": "^3.1.0" + } + }, + "@internal/randomphrase": { + "version": "file:lib/randomphrase" + }, + "@internal/requests": { + "version": "file:lib/model/lib/requests" + }, + "@internal/router": { + "version": "file:lib/router" + }, + "@internal/shared": { + "version": "file:lib/model/lib/shared" + }, + "@internal/validate": { + "version": "file:lib/validate", + "requires": { + "ajv": "^6.7.0", + "json-schema-ref-parser": "^6.0.3", + "openapi-jsonschema-parameters": "^1.1.0" + } + }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", + "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", + "dev": true + }, + "@jest/console": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-26.6.2.tgz", + "integrity": "sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^26.6.2", + "jest-util": "^26.6.2", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/core": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-26.6.3.tgz", + "integrity": "sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw==", + "dev": true, + "requires": { + "@jest/console": "^26.6.2", + "@jest/reporters": "^26.6.2", + "@jest/test-result": "^26.6.2", + "@jest/transform": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.4", + "jest-changed-files": "^26.6.2", + "jest-config": "^26.6.3", + "jest-haste-map": "^26.6.2", + "jest-message-util": "^26.6.2", + "jest-regex-util": "^26.0.0", + "jest-resolve": "^26.6.2", + "jest-resolve-dependencies": "^26.6.3", + "jest-runner": "^26.6.3", + "jest-runtime": "^26.6.3", + "jest-snapshot": "^26.6.2", + "jest-util": "^26.6.2", + "jest-validate": "^26.6.2", + "jest-watcher": "^26.6.2", + "micromatch": "^4.0.2", + "p-each-series": "^2.1.0", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/environment": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz", + "integrity": "sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==", + "dev": true, + "requires": { + "@jest/fake-timers": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/node": "*", + "jest-mock": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/fake-timers": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz", + "integrity": "sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@sinonjs/fake-timers": "^6.0.1", + "@types/node": "*", + "jest-message-util": "^26.6.2", + "jest-mock": "^26.6.2", + "jest-util": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/globals": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-26.6.2.tgz", + "integrity": "sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA==", + "dev": true, + "requires": { + "@jest/environment": "^26.6.2", + "@jest/types": "^26.6.2", + "expect": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/reporters": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-26.6.2.tgz", + "integrity": "sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^26.6.2", + "@jest/test-result": "^26.6.2", + "@jest/transform": "^26.6.2", + "@jest/types": "^26.6.2", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.4", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^4.0.3", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "jest-haste-map": "^26.6.2", + "jest-resolve": "^26.6.2", + "jest-util": "^26.6.2", + "jest-worker": "^26.6.2", + "node-notifier": "^8.0.0", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^7.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/source-map": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-26.6.2.tgz", + "integrity": "sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.4", + "source-map": "^0.6.0" + } + }, + "@jest/test-result": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-26.6.2.tgz", + "integrity": "sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ==", + "dev": true, + "requires": { + "@jest/console": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/test-sequencer": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz", + "integrity": "sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw==", + "dev": true, + "requires": { + "@jest/test-result": "^26.6.2", + "graceful-fs": "^4.2.4", + "jest-haste-map": "^26.6.2", + "jest-runner": "^26.6.3", + "jest-runtime": "^26.6.3" + } + }, + "@jest/transform": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz", + "integrity": "sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^26.6.2", + "babel-plugin-istanbul": "^6.0.0", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.4", + "jest-haste-map": "^26.6.2", + "jest-regex-util": "^26.0.0", + "jest-util": "^26.6.2", + "micromatch": "^4.0.2", + "pirates": "^4.0.1", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/types": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz", + "integrity": "sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^15.0.0", + "chalk": "^3.0.0" + } + }, + "@korzio/djv-draft-04": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@korzio/djv-draft-04/-/djv-draft-04-2.0.1.tgz", + "integrity": "sha512-MeTVcNsfCIYxK6T7jW1sroC7dBAb4IfLmQe6RoCqlxHN5NFkzNpgdnBPR+/0D2wJDUJHM9s9NQv+ouhxKjvUjg==", + "dev": true, + "optional": true + }, + "@mojaloop/sdk-standard-components": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@mojaloop/sdk-standard-components/-/sdk-standard-components-13.1.0.tgz", + "integrity": "sha512-qbXAyL99oHDzy/S7SIIa015/HJEAyhlBM86Qew//1dcMVfZZU/f/ww2aDCqH9p1T4EseGGZKyvP9Qgb8RCt42g==", + "requires": { + "base64url": "3.0.1", + "fast-safe-stringify": "^2.0.7", + "ilp-packet": "2.2.0", + "jsonwebtoken": "8.5.1", + "jws": "4.0.0" + } + }, + "@sinonjs/commons": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz", + "integrity": "sha512-892K+kWUUi3cl+LlqEWIDrhvLgdL79tECi8JZUyq6IviKy/DNhuzCRlbHUjxK89f4ypPMMaFnFuR9Ie6DoIMsw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@types/babel__core": { + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.12.tgz", + "integrity": "sha512-wMTHiiTiBAAPebqaPiPDLFA4LYPKr6Ph0Xq/6rq1Ur3v66HXyG+clfR9CNETkD7MQS8ZHvpQOtA53DLws5WAEQ==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.2.tgz", + "integrity": "sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.0.tgz", + "integrity": "sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.11.0.tgz", + "integrity": "sha512-kSjgDMZONiIfSH1Nxcr5JIRMwUetDki63FSQfpTCz8ogF3Ulqm8+mr5f78dUYs6vMiB6gBusQqfQmBvHZj/lwg==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0" + } + }, + "@types/formidable": { + "version": "1.0.32", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-1.0.32.tgz", + "integrity": "sha512-jOAB5+GFW+C+2xdvUcpd/CnYg2rD5xCyagJLBJU+9PB4a/DKmsAqS9yZI3j/Q9zwvM7ztPHaAIH1ijzp4cezdQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/graceful-fs": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.4.tgz", + "integrity": "sha512-mWA/4zFQhfvOA8zWkXobwJvBD7vzcxgrOQ0J5CH1votGqdq9m7+FwtGaqyCZqC3NyyBkc9z4m+iry4LlqcMWJg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", + "integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz", + "integrity": "sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, + "@types/jest": { + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz", + "integrity": "sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw==", + "dev": true, + "requires": { + "jest-diff": "^25.2.1", + "pretty-format": "^25.2.1" + } + }, + "@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", + "dev": true + }, + "@types/node": { + "version": "14.14.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.17.tgz", + "integrity": "sha512-G0lD1/7qD60TJ/mZmhog76k7NcpLWkPVGgzkRy3CTlnFu4LUQh5v2Wa661z6vnXmD8EQrnALUyf0VRtrACYztw==" + }, + "@types/normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "@types/prettier": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.1.6.tgz", + "integrity": "sha512-6gOkRe7OIioWAXfnO/2lFiv+SJichKVSys1mSsgyrYHSEjk8Ctv4tSR/Odvnu+HWlH2C8j53dahU03XmQdd5fA==", + "dev": true + }, + "@types/stack-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz", + "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==", + "dev": true + }, + "@types/uuid": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==" + }, + "@types/yargs": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.12.tgz", + "integrity": "sha512-f+fD/fQAo3BCbCDlrUpznF1A5Zp9rB0noS5vnoormHSIPFKL0Z2DcUJ3Gxp5ytH4uLRNxy7AwYUC9exZzqGMAw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-20.2.0.tgz", + "integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==", + "dev": true + }, + "abab": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", + "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", + "dev": true + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "requires": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "acorn-jsx": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", + "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", + "dev": true + }, + "acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-escapes": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", + "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", + "dev": true, + "requires": { + "type-fest": "^0.11.0" + }, + "dependencies": { + "type-fest": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", + "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", + "dev": true + } + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, + "anymatch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", + "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "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 + }, + "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==", + "dev": true + }, + "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-includes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.2.tgz", + "integrity": "sha512-w2GspexNQpx+PutG3QpT437/BenZBj0M/MZGn5mzv/MofYqo0xmRHzn4lFsoDlWJ+THYsGJmFlW68WlDFx7VRw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1", + "get-intrinsic": "^1.0.1", + "is-string": "^1.0.5" + } + }, + "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 + }, + "array.prototype.flat": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz", + "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + } + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "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 + }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "audit-resolve-core": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/audit-resolve-core/-/audit-resolve-core-1.1.8.tgz", + "integrity": "sha512-F3IWaxu1Xw4OokmtG9hkmsKoJt8DQS7RZvot52zXHsANKvzFRMKVNTP1DAz1ztlRGmJx1XV16PcE+6m35bYoTA==", + "dev": true, + "requires": { + "concat-stream": "^1.6.2", + "debug": "^4.1.1", + "djv": "^2.1.2", + "spawn-shell": "^2.1.0", + "yargs-parser": "^18.1.3" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "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=", + "dev": true + }, + "aws4": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", + "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", + "dev": true + }, + "babel-jest": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", + "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "dev": true, + "requires": { + "@jest/transform": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/babel__core": "^7.1.7", + "babel-plugin-istanbul": "^6.0.0", + "babel-preset-jest": "^26.6.2", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "babel-plugin-istanbul": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz", + "integrity": "sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^4.0.0", + "test-exclude": "^6.0.0" + } + }, + "babel-plugin-jest-hoist": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz", + "integrity": "sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw==", + "dev": true, + "requires": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + } + }, + "babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "requires": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + } + }, + "babel-preset-jest": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz", + "integrity": "sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ==", + "dev": true, + "requires": { + "babel-plugin-jest-hoist": "^26.6.2", + "babel-preset-current-node-syntax": "^1.0.0" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "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.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "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.0" + } + }, + "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.0" + } + }, + "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.0" + } + }, + "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" + } + } + } + }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bignumber.js": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-5.0.0.tgz", + "integrity": "sha512-KWTu6ZMVk9sxlDJQh2YH1UOnfDP8O8TpxUxgQG/vKASoSnEjK9aVuOueFaPcQEYQ5fyNXNTOYwYw3099RYebWg==" + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, + "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-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "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" + } + }, + "cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "requires": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + } + }, + "call-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz", + "integrity": "sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.0" + } + }, + "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=" + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "capture-exit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", + "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==", + "dev": true, + "requires": { + "rsvp": "^4.8.4" + } + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cjs-module-lexer": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz", + "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==", + "dev": true + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.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.0" + } + } + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "co-bluebird": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/co-bluebird/-/co-bluebird-1.1.0.tgz", + "integrity": "sha1-yLnzqTIKftMJh9zKGlw8/1llXHw=", + "requires": { + "bluebird": "^2.10.0", + "co-use": "^1.1.0" + }, + "dependencies": { + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + } + } + }, + "co-body": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.1.0.tgz", + "integrity": "sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==", + "requires": { + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + } + }, + "co-use": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/co-use/-/co-use-1.1.0.tgz", + "integrity": "sha1-xrs83xDLc17Kqdru2kbXJclKTmI=" + }, + "collect-v8-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", + "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", + "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.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "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.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "confusing-browser-globals": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz", + "integrity": "sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==", + "dev": true + }, + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", + "dev": true + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "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==" + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, + "cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "requires": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, + "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-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": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true + }, + "cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "requires": { + "cssom": "~0.3.6" + }, + "dependencies": { + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + } + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "data-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dev": true, + "requires": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + } + }, + "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" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decimal.js": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.1.tgz", + "integrity": "sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==", + "dev": true + }, + "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-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 + }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "dev": true + }, + "default-shell": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/default-shell/-/default-shell-1.0.1.tgz", + "integrity": "sha1-dSMEvdxhdPSespy5iP7qC4gTyLw=", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "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.0" + } + }, + "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.0" + } + }, + "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" + } + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true + }, + "diff-sequences": { + "version": "25.2.6", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz", + "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==", + "dev": true + }, + "djv": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/djv/-/djv-2.1.4.tgz", + "integrity": "sha512-giDn+BVbtLlwtkvtcsZjbjzpALHB77skiv3FIu6Wp8b5j8BunDcVJYH0cGUaexp6s0Sb7IkquXXjsLBJhXwQpA==", + "dev": true, + "requires": { + "@korzio/djv-draft-04": "^2.0.1" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "dev": true, + "requires": { + "webidl-conversions": "^5.0.0" + }, + "dependencies": { + "webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true + } + } + }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + }, + "double-ended-queue": { + "version": "2.1.0-0", + "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz", + "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw=" + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "emittery": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz", + "integrity": "sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "env-var": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/env-var/-/env-var-6.3.0.tgz", + "integrity": "sha512-gaNzDZuVaJQJlP2SigAZLu/FieZN5MzdN7lgHNehESwlRanHwGQ/WUtJ7q//dhrj3aGBZM45yEaKOuvSJaf4mA==" + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "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.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "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" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "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", + "word-wrap": "~1.2.3" + } + }, + "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 + }, + "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" + } + } + } + }, + "eslint": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.16.0.tgz", + "integrity": "sha512-iVWPS785RuDA4dWuhhgXTNrGxHHK3a8HLSMBgbbU59ruJDubUraXN8N5rn7kb8tG6sjg74eE0RA3YWT51eusEw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@eslint/eslintrc": "^0.2.2", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.2.0", + "esutils": "^2.0.2", + "file-entry-cache": "^6.0.0", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.0.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.4", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "eslint-config-airbnb-base": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz", + "integrity": "sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==", + "dev": true, + "requires": { + "confusing-browser-globals": "^1.0.10", + "object.assign": "^4.1.2", + "object.entries": "^1.1.2" + } + }, + "eslint-import-resolver-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz", + "integrity": "sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "resolve": "^1.13.1" + }, + "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" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-module-utils": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz", + "integrity": "sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "pkg-dir": "^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" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-plugin-import": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz", + "integrity": "sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==", + "dev": true, + "requires": { + "array-includes": "^3.1.1", + "array.prototype.flat": "^1.2.3", + "contains-path": "^0.1.0", + "debug": "^2.6.9", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "^0.3.4", + "eslint-module-utils": "^2.6.0", + "has": "^1.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.1", + "read-pkg-up": "^2.0.0", + "resolve": "^1.17.0", + "tsconfig-paths": "^3.9.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" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", + "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", + "dev": true + }, + "espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "requires": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "esquery": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", + "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "exec-sh": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", + "integrity": "sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A==", + "dev": true + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "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 + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.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": "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.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "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" + } + }, + "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.0" + } + }, + "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.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "expect": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz", + "integrity": "sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-styles": "^4.0.0", + "jest-get-type": "^26.3.0", + "jest-matcher-utils": "^26.6.2", + "jest-message-util": "^26.6.2", + "jest-regex-util": "^26.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "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" + }, + "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" + } + } + } + }, + "extensible-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/extensible-error/-/extensible-error-1.0.2.tgz", + "integrity": "sha512-kXU1FiTsGT8PyMKtFM074RK/VBpzwuQJicAHqBpsPDeTXBQiSALPjkjKXlyKdG/GP6lR7bBaEkq8qdoO2geu9g==" + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "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" + }, + "dependencies": { + "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.0" + } + }, + "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.0" + } + }, + "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.0" + } + }, + "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.0" + } + }, + "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" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "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 + }, + "fast-safe-stringify": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", + "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==" + }, + "fb-watchman": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", + "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", + "dev": true, + "requires": { + "bser": "2.1.1" + } + }, + "file-entry-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.0.tgz", + "integrity": "sha512-fqoO76jZ3ZnYrXLDRxBR1YvOvc0k844kcOg40bgsPrE25LAb/PDqTY+ho64Xh2c8ZXgIKldchCFHczG2UVRcWA==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.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": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.0.tgz", + "integrity": "sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==", + "dev": true + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "format-util": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz", + "integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==" + }, + "formidable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==" + }, + "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" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.2.1.tgz", + "integrity": "sha512-bTLYHSeC0UH/EFXS9KqWnXuOl/wHK5Z/d+ghd5AsFMYN7wIGkUCOJyzy88+wJKkZPGON8u4Z9f6U4FdgURE9qA==", + "dev": true, + "optional": 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 + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.2.tgz", + "integrity": "sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "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=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", + "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", + "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, + "growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", + "dev": true, + "optional": true + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "dev": true, + "requires": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "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.0" + } + }, + "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.0.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.5" + } + } + } + }, + "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.5" + } + } + } + }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dev": true, + "requires": { + "whatwg-encoding": "^1.0.5" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "http-assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", + "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "requires": { + "deep-equal": "~1.0.1", + "http-errors": "~1.7.2" + } + }, + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "ilp-packet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ilp-packet/-/ilp-packet-2.2.0.tgz", + "integrity": "sha1-qHJcwmMxxuLGU1OKEGPVUBQwXjE=", + "requires": { + "bignumber.js": "^5.0.0", + "extensible-error": "^1.0.2", + "long": "^3.2.0", + "oer-utils": "^1.3.2" + } + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "import-local": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz", + "integrity": "sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + } + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflation": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.0.0.tgz", + "integrity": "sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ip-regex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", + "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", + "dev": true + }, + "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.0.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.5" + } + } + } + }, + "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-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-callable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", + "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", + "dev": true + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz", + "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "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.0.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.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "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.0.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-docker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.1.1.tgz", + "integrity": "sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw==", + "dev": true, + "optional": true + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-generator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-generator/-/is-generator-1.0.3.tgz", + "integrity": "sha1-wUwhBX7TbjKNuANHlmxpP4hjifM=" + }, + "is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true + }, + "is-generator-function": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.8.tgz", + "integrity": "sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==" + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, + "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" + } + }, + "is-potential-custom-element-name": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz", + "integrity": "sha1-DFLlS8yjkbssSUsh6GJtczbG45c=", + "dev": true + }, + "is-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", + "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "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-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "optional": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "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 + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", + "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", + "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "javascript-state-machine": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/javascript-state-machine/-/javascript-state-machine-3.1.0.tgz", + "integrity": "sha512-BwhYxQ1OPenBPXC735RgfB+ZUG8H3kjsx8hrYTgWnoy6TPipEy4fiicyhT2lxRKAXq9pG7CfFT8a2HLr6Hmwxg==" + }, + "jest": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", + "integrity": "sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q==", + "dev": true, + "requires": { + "@jest/core": "^26.6.3", + "import-local": "^3.0.2", + "jest-cli": "^26.6.3" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-cli": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-26.6.3.tgz", + "integrity": "sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg==", + "dev": true, + "requires": { + "@jest/core": "^26.6.3", + "@jest/test-result": "^26.6.2", + "@jest/types": "^26.6.2", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.4", + "import-local": "^3.0.2", + "is-ci": "^2.0.0", + "jest-config": "^26.6.3", + "jest-util": "^26.6.2", + "jest-validate": "^26.6.2", + "prompts": "^2.0.1", + "yargs": "^15.4.1" + } + } + } + }, + "jest-changed-files": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", + "integrity": "sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "execa": "^4.0.0", + "throat": "^5.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + } + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + } + } + }, + "jest-config": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.6.3.tgz", + "integrity": "sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/test-sequencer": "^26.6.3", + "@jest/types": "^26.6.2", + "babel-jest": "^26.6.3", + "chalk": "^4.0.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.4", + "jest-environment-jsdom": "^26.6.2", + "jest-environment-node": "^26.6.2", + "jest-get-type": "^26.3.0", + "jest-jasmine2": "^26.6.3", + "jest-regex-util": "^26.0.0", + "jest-resolve": "^26.6.2", + "jest-util": "^26.6.2", + "jest-validate": "^26.6.2", + "micromatch": "^4.0.2", + "pretty-format": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "jest-diff": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz", + "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "diff-sequences": "^25.2.6", + "jest-get-type": "^25.2.6", + "pretty-format": "^25.5.0" + } + }, + "jest-docblock": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz", + "integrity": "sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==", + "dev": true, + "requires": { + "detect-newline": "^3.0.0" + } + }, + "jest-each": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-26.6.2.tgz", + "integrity": "sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "chalk": "^4.0.0", + "jest-get-type": "^26.3.0", + "jest-util": "^26.6.2", + "pretty-format": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "jest-environment-jsdom": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz", + "integrity": "sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q==", + "dev": true, + "requires": { + "@jest/environment": "^26.6.2", + "@jest/fake-timers": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/node": "*", + "jest-mock": "^26.6.2", + "jest-util": "^26.6.2", + "jsdom": "^16.4.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-environment-node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.6.2.tgz", + "integrity": "sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag==", + "dev": true, + "requires": { + "@jest/environment": "^26.6.2", + "@jest/fake-timers": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/node": "*", + "jest-mock": "^26.6.2", + "jest-util": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-get-type": { + "version": "25.2.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz", + "integrity": "sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig==", + "dev": true + }, + "jest-haste-map": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz", + "integrity": "sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.1.2", + "graceful-fs": "^4.2.4", + "jest-regex-util": "^26.0.0", + "jest-serializer": "^26.6.2", + "jest-util": "^26.6.2", + "jest-worker": "^26.6.2", + "micromatch": "^4.0.2", + "sane": "^4.0.3", + "walker": "^1.0.7" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-jasmine2": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz", + "integrity": "sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.0", + "@jest/environment": "^26.6.2", + "@jest/source-map": "^26.6.2", + "@jest/test-result": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^26.6.2", + "is-generator-fn": "^2.0.0", + "jest-each": "^26.6.2", + "jest-matcher-utils": "^26.6.2", + "jest-message-util": "^26.6.2", + "jest-runtime": "^26.6.3", + "jest-snapshot": "^26.6.2", + "jest-util": "^26.6.2", + "pretty-format": "^26.6.2", + "throat": "^5.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "jest-junit": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-10.0.0.tgz", + "integrity": "sha512-dbOVRyxHprdSpwSAR9/YshLwmnwf+RSl5hf0kCGlhAcEeZY9aRqo4oNmaT0tLC16Zy9D0zekDjWkjHGjXlglaQ==", + "dev": true, + "requires": { + "jest-validate": "^24.9.0", + "mkdirp": "^0.5.1", + "strip-ansi": "^5.2.0", + "uuid": "^3.3.3", + "xml": "^1.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz", + "integrity": "sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^13.0.0" + } + }, + "@types/yargs": { + "version": "13.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.11.tgz", + "integrity": "sha512-NRqD6T4gktUrDi1o1wLH3EKC1o2caCr7/wR87ODcbVITQF106OM3sFN92ysZ++wqelOd1CTzatnOBRDYYG6wGQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "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 + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "jest-get-type": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz", + "integrity": "sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q==", + "dev": true + }, + "jest-validate": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-24.9.0.tgz", + "integrity": "sha512-HPIt6C5ACwiqSiwi+OfSSHbK8sG7akG8eATl+IPKaeIjtPOeBUd/g3J7DghugzxrGjI93qS/+RPKe1H6PqvhRQ==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "camelcase": "^5.3.1", + "chalk": "^2.0.1", + "jest-get-type": "^24.9.0", + "leven": "^3.1.0", + "pretty-format": "^24.9.0" + } + }, + "pretty-format": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz", + "integrity": "sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "ansi-regex": "^4.0.0", + "ansi-styles": "^3.2.0", + "react-is": "^16.8.4" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } + } + }, + "jest-leak-detector": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz", + "integrity": "sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg==", + "dev": true, + "requires": { + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "jest-matcher-utils": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz", + "integrity": "sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "diff-sequences": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true + }, + "jest-diff": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", + "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "jest-message-util": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz", + "integrity": "sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/types": "^26.6.2", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.2", + "pretty-format": "^26.6.2", + "slash": "^3.0.0", + "stack-utils": "^2.0.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "jest-mock": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz", + "integrity": "sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@types/node": "*" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-pnp-resolver": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", + "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", + "dev": true + }, + "jest-regex-util": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz", + "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==", + "dev": true + }, + "jest-resolve": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.6.2.tgz", + "integrity": "sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^26.6.2", + "read-pkg-up": "^7.0.1", + "resolve": "^1.18.1", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", + "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + } + } + } + }, + "jest-resolve-dependencies": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz", + "integrity": "sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "jest-regex-util": "^26.0.0", + "jest-snapshot": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-runner": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-26.6.3.tgz", + "integrity": "sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ==", + "dev": true, + "requires": { + "@jest/console": "^26.6.2", + "@jest/environment": "^26.6.2", + "@jest/test-result": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.7.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.4", + "jest-config": "^26.6.3", + "jest-docblock": "^26.0.0", + "jest-haste-map": "^26.6.2", + "jest-leak-detector": "^26.6.2", + "jest-message-util": "^26.6.2", + "jest-resolve": "^26.6.2", + "jest-runtime": "^26.6.3", + "jest-util": "^26.6.2", + "jest-worker": "^26.6.2", + "source-map-support": "^0.5.6", + "throat": "^5.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-runtime": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.6.3.tgz", + "integrity": "sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==", + "dev": true, + "requires": { + "@jest/console": "^26.6.2", + "@jest/environment": "^26.6.2", + "@jest/fake-timers": "^26.6.2", + "@jest/globals": "^26.6.2", + "@jest/source-map": "^26.6.2", + "@jest/test-result": "^26.6.2", + "@jest/transform": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0", + "cjs-module-lexer": "^0.6.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.4", + "jest-config": "^26.6.3", + "jest-haste-map": "^26.6.2", + "jest-message-util": "^26.6.2", + "jest-mock": "^26.6.2", + "jest-regex-util": "^26.0.0", + "jest-resolve": "^26.6.2", + "jest-snapshot": "^26.6.2", + "jest-util": "^26.6.2", + "jest-validate": "^26.6.2", + "slash": "^3.0.0", + "strip-bom": "^4.0.0", + "yargs": "^15.4.1" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + } + } + }, + "jest-serializer": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz", + "integrity": "sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==", + "dev": true, + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.4" + } + }, + "jest-snapshot": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.6.2.tgz", + "integrity": "sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0", + "@jest/types": "^26.6.2", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.0.0", + "chalk": "^4.0.0", + "expect": "^26.6.2", + "graceful-fs": "^4.2.4", + "jest-diff": "^26.6.2", + "jest-get-type": "^26.3.0", + "jest-haste-map": "^26.6.2", + "jest-matcher-utils": "^26.6.2", + "jest-message-util": "^26.6.2", + "jest-resolve": "^26.6.2", + "natural-compare": "^1.4.0", + "pretty-format": "^26.6.2", + "semver": "^7.3.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "diff-sequences": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true + }, + "jest-diff": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", + "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "jest-util": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz", + "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@types/node": "*", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "is-ci": "^2.0.0", + "micromatch": "^4.0.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-validate": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-26.6.2.tgz", + "integrity": "sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "camelcase": "^6.0.0", + "chalk": "^4.0.0", + "jest-get-type": "^26.3.0", + "leven": "^3.1.0", + "pretty-format": "^26.6.2" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "jest-watcher": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.6.2.tgz", + "integrity": "sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ==", + "dev": true, + "requires": { + "@jest/test-result": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^26.6.2", + "string-length": "^4.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true + }, + "jsdom": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.4.0.tgz", + "integrity": "sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w==", + "dev": true, + "requires": { + "abab": "^2.0.3", + "acorn": "^7.1.1", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.2.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.0", + "domexception": "^2.0.1", + "escodegen": "^1.14.1", + "html-encoding-sniffer": "^2.0.1", + "is-potential-custom-element-name": "^1.0.0", + "nwsapi": "^2.2.0", + "parse5": "5.1.1", + "request": "^2.88.2", + "request-promise-native": "^1.0.8", + "saxes": "^5.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^3.0.1", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0", + "ws": "^7.2.3", + "xml-name-validator": "^3.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-schema-ref-parser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-6.1.0.tgz", + "integrity": "sha512-pXe9H1m6IgIpXmE5JSb8epilNTGsmTb2iPohAXpOdhqGFbQjNeHHsZxU+C8w6T81GZxSPFLeUoqDJmzxx5IGuw==", + "requires": { + "call-me-maybe": "^1.0.1", + "js-yaml": "^3.12.1", + "ono": "^4.0.11" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "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 + }, + "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=", + "dev": true + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "jsonlines": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz", + "integrity": "sha1-T80kbcXQ44aRkHxEqwAveC0dlMw=", + "dev": true + }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + } + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + }, + "dependencies": { + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + } + } + }, + "keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "requires": { + "tsscmp": "1.0.6" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true + }, + "koa": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.0.tgz", + "integrity": "sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==", + "requires": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.8.0", + "debug": "~3.1.0", + "delegates": "^1.0.0", + "depd": "^1.1.2", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^1.2.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + } + }, + "koa-body": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/koa-body/-/koa-body-4.2.0.tgz", + "integrity": "sha512-wdGu7b9amk4Fnk/ytH8GuWwfs4fsB5iNkY8kZPpgQVb04QZSv85T0M8reb+cJmvLE8cjPYvBzRikD3s6qz8OoA==", + "requires": { + "@types/formidable": "^1.0.31", + "co-body": "^5.1.1", + "formidable": "^1.1.1" + }, + "dependencies": { + "co-body": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-5.2.0.tgz", + "integrity": "sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==", + "requires": { + "inflation": "^2.0.0", + "qs": "^6.4.0", + "raw-body": "^2.2.0", + "type-is": "^1.6.14" + } + } + } + }, + "koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" + }, + "koa-convert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", + "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "requires": { + "co": "^4.6.0", + "koa-compose": "^3.0.0" + }, + "dependencies": { + "koa-compose": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", + "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", + "requires": { + "any-promise": "^1.1.0" + } + } + } + }, + "koa2-oauth-server": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/koa2-oauth-server/-/koa2-oauth-server-1.0.0.tgz", + "integrity": "sha512-MsLHCOhtOEeLEQsyko2IeGwSwhzXf8DXDYohJlMaPeb12J+xYq0cp9t9r9aQuKpO+KeEtuxDFVcT24fG1Rbcdg==", + "requires": { + "bluebird": "^3.0.5", + "koa": "^2.3.0", + "oauth2-server": "git://github.com/oauthjs/node-oauth2-server.git#dev" + } + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "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.2", + "parse-json": "^2.2.0", + "pify": "^2.0.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.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, + "long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s=" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "makeerror": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", + "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=", + "dev": true, + "requires": { + "tmpl": "1.0.x" + } + }, + "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-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.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-options": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-1.0.1.tgz", + "integrity": "sha512-iuPV41VWKWBIOpBsjoxjDZw8/GbSfZ2mk7N1453bwMrfzdrIk7EzBd+8UVR6rkw67th7xnk9Dytl3J+lHPdxvg==", + "dev": true, + "requires": { + "is-plain-obj": "^1.1" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "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.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "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.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "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 + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "nock": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/nock/-/nock-12.0.3.tgz", + "integrity": "sha512-QNb/j8kbFnKCiyqi9C5DD0jH/FubFGj5rt9NQFONXwQm3IPB0CULECg/eS3AU1KgZb/6SwUa4/DTRKhVxkGABw==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.13", + "propagate": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", + "dev": true + }, + "node-modules-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", + "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", + "dev": true + }, + "node-notifier": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", + "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", + "dev": true, + "optional": true, + "requires": { + "growly": "^1.3.0", + "is-wsl": "^2.2.0", + "semver": "^7.3.2", + "shellwords": "^0.1.1", + "uuid": "^8.3.0", + "which": "^2.0.2" + }, + "dependencies": { + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-audit-resolver": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/npm-audit-resolver/-/npm-audit-resolver-2.2.0.tgz", + "integrity": "sha512-nBhxrc0Y34vIFl38G42PkWSBEbOAL3Gg6aRxm1hYzM4Vm+Rv0ozALj2LixdeytkUC2OGWP4QqCF0fKAb14NnPQ==", + "dev": true, + "requires": { + "audit-resolve-core": "^1.1.7", + "chalk": "^2.4.2", + "djv": "^2.1.2", + "jsonlines": "^0.1.1", + "read": "^1.0.7", + "spawn-shell": "^2.1.0", + "yargs-parser": "^13.1.1", + "yargs-unparser": "^1.5.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "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 + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.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.0" + }, + "dependencies": { + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + } + } + }, + "nwsapi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", + "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", + "dev": true + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "oauth2-server": { + "version": "git://github.com/oauthjs/node-oauth2-server.git#45b508afa2f7b6272df54d324b95bc3f7528cdbb", + "from": "git://github.com/oauthjs/node-oauth2-server.git#dev", + "requires": { + "basic-auth": "^2.0.0", + "bluebird": "^3.5.1", + "lodash": "^4.17.10", + "promisify-any": "^2.0.1", + "statuses": "^1.5.0", + "type-is": "^1.6.16" + } + }, + "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.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "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.0" + } + }, + "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.5" + } + } + } + }, + "object-inspect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "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.0" + } + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.entries": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.3.tgz", + "integrity": "sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1", + "has": "^1.0.3" + } + }, + "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" + } + }, + "object.values": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.2.tgz", + "integrity": "sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1", + "has": "^1.0.3" + } + }, + "oer-utils": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/oer-utils/-/oer-utils-1.3.4.tgz", + "integrity": "sha1-sqmtvJK8GRVaKgDwRWg9Hm1KyCM=" + }, + "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" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=" + }, + "ono": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/ono/-/ono-4.0.11.tgz", + "integrity": "sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g==", + "requires": { + "format-util": "^1.0.3" + } + }, + "openapi-jsonschema-parameters": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/openapi-jsonschema-parameters/-/openapi-jsonschema-parameters-1.2.0.tgz", + "integrity": "sha512-i2vBBFiRbOwYSvt5OG9hayJ7WUe/nl9Y151Ki1QtHb8M0zdYs2wkDhywVJnapq4/gPlrD1vmSVsYDrAjcBRJTQ==", + "requires": { + "openapi-types": "1.3.5" + } + }, + "openapi-response-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/openapi-response-validator/-/openapi-response-validator-4.0.0.tgz", + "integrity": "sha512-bIG8bpHT/vE+Dtz4aVyfQnweXtUdvxvJf5/D6Uu98UGf3T42Ez940ctwnlmDCQxTPqdu0yLFbMoiNf/A3jYCIg==", + "dev": true, + "requires": { + "ajv": "^6.5.4", + "openapi-types": "1.3.5" + } + }, + "openapi-types": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-1.3.5.tgz", + "integrity": "sha512-11oi4zYorsgvg5yBarZplAqbpev5HkuVNPlZaPTknPDzAynq+lnJdXAmruGWP0s+dNYZS7bjM+xrTpJw7184Fg==" + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "p-each-series": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", + "integrity": "sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==", + "dev": true + }, + "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.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "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.1.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 + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "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.2.0" + } + }, + "parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "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-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "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.0.0" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pirates": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz", + "integrity": "sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==", + "dev": true, + "requires": { + "node-modules-regexp": "^1.0.0" + } + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.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.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "pretty-format": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz", + "integrity": "sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ==", + "dev": true, + "requires": { + "@jest/types": "^25.5.0", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^16.12.0" + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "promisify-any": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promisify-any/-/promisify-any-2.0.1.tgz", + "integrity": "sha1-QD4AqIE/F1JCq1D+M6afjuzkcwU=", + "requires": { + "bluebird": "^2.10.0", + "co-bluebird": "^1.1.0", + "is-generator": "^1.0.2" + }, + "dependencies": { + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + } + } + }, + "prompts": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz", + "integrity": "sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ==", + "dev": true, + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, + "psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "qs": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", + "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" + }, + "raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "dev": true, + "requires": { + "mute-stream": "~0.0.4" + } + }, + "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.3.2", + "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.0.0", + "read-pkg": "^2.0.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "redis": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-2.8.0.tgz", + "integrity": "sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==", + "requires": { + "double-ended-queue": "^2.1.0-0", + "redis-commands": "^1.2.0", + "redis-parser": "^2.6.0" + } + }, + "redis-commands": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.6.0.tgz", + "integrity": "sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ==" + }, + "redis-mock": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/redis-mock/-/redis-mock-0.49.0.tgz", + "integrity": "sha512-FW/cLZvF1PAVN/PYIwXf1vQRoJCyYCwUMtq8BXRwrvb9LNNAT4RKXM02Qlt6qSkC/98hmHlU2EGoQoxVy3E2lA==", + "dev": true + }, + "redis-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz", + "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=" + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", + "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", + "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=", + "dev": true + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } + } + }, + "request-promise-core": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", + "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", + "dev": true, + "requires": { + "lodash": "^4.17.19" + } + }, + "request-promise-native": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", + "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", + "dev": true, + "requires": { + "request-promise-core": "1.1.4", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + }, + "dependencies": { + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.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": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "requires": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "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 + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rsvp": { + "version": "4.8.5", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", + "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", + "dev": true + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sane": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz", + "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==", + "dev": true, + "requires": { + "@cnakazawa/watch": "^1.0.3", + "anymatch": "^2.0.0", + "capture-exit": "^2.0.0", + "exec-sh": "^0.3.2", + "execa": "^1.0.0", + "fb-watchman": "^2.0.0", + "micromatch": "^3.1.4", + "minimist": "^1.1.1", + "walker": "~1.0.5" + }, + "dependencies": { + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "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.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "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.0" + } + } + } + }, + "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.0" + }, + "dependencies": { + "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.0" + } + } + } + }, + "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.0.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.5" + } + } + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "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" + } + } + } + }, + "saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + }, + "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-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "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.0" + } + } + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.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" + } + }, + "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.0" + } + }, + "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.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "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.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "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.0" + } + }, + "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.0" + } + }, + "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.0" + } + }, + "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" + } + } + } + }, + "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.0" + }, + "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.5" + } + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "dev": true, + "requires": { + "atob": "^2.1.2", + "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.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "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 + }, + "spawn-shell": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spawn-shell/-/spawn-shell-2.1.0.tgz", + "integrity": "sha512-mjlYAQbZPHd4YsoHEe+i0Xbp9sJefMKN09JPp80TqrjC5NSuo+y1RG3NBireJlzl1dDV2NIkIfgS6coXtyqN/A==", + "dev": true, + "requires": { + "default-shell": "^1.0.1", + "merge-options": "~1.0.1", + "npm-run-path": "^2.0.2" + } + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz", + "integrity": "sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==", + "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.0" + } + }, + "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.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw==", + "dev": true, + "requires": { + "escape-string-regexp": "^2.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, + "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.0" + } + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "dev": true + }, + "string-length": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.1.tgz", + "integrity": "sha512-PKyXUd0LK0ePjSOnWn34V2uD6acUWev9uy0Ft05k0E8xRW+SKcA0F7eMr7h5xlzfn+4O3N+55rduYyet3Jk+jw==", + "dev": true, + "requires": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + } + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "string.prototype.trimend": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz", + "integrity": "sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz", + "integrity": "sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "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-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-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + } + }, + "supertest": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-4.0.2.tgz", + "integrity": "sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ==", + "dev": true, + "requires": { + "methods": "^1.1.2", + "superagent": "^3.8.3" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "supports-hyperlinks": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz", + "integrity": "sha512-zoE5/e+dnEijk6ASB6/qrK+oYdm2do1hjoLWrqUC/8WEIW1gbxFcKuBof7sW8ArN6e+AYvsE8HBGiVRWL/F5CA==", + "dev": true, + "requires": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + } + }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "table": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/table/-/table-6.0.4.tgz", + "integrity": "sha512-sBT4xRLdALd+NFBvwOz8bw4b15htyythha+q+DVZqy2RS08PPC8O2sZFgJYEY7bJvbCFKccs+WIZ/cd+xxTWCw==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "lodash": "^4.17.20", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.0" + } + }, + "terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "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 + }, + "throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "dev": true + }, + "tmpl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", + "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", + "dev": true + }, + "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 + }, + "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.0.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.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tough-cookie": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", + "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", + "dev": true, + "requires": { + "ip-regex": "^2.1.0", + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "tr46": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz", + "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==", + "dev": true, + "requires": { + "punycode": "^2.1.1" + } + }, + "tsconfig-paths": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", + "integrity": "sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==", + "dev": true, + "requires": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.0", + "strip-bom": "^3.0.0" + } + }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "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.0" + }, + "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.3", + "has-values": "^0.1.4", + "isobject": "^2.0.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 + } + } + }, + "uri-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", + "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", + "requires": { + "punycode": "^2.1.0" + } + }, + "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": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "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 + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "uuidv4": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/uuidv4/-/uuidv4-6.2.6.tgz", + "integrity": "sha512-vFyL4jugB/ln1ux1gXLlBMBv424Dn86EaBMoqUH1K6XI3XuriaWLeRUzH4iWwPu+BOJiw4hc4TjvrPmk+H+ZBQ==", + "requires": { + "@types/uuid": "8.3.0", + "uuid": "8.3.2" + } + }, + "v8-compile-cache": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz", + "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==", + "dev": true + }, + "v8-to-istanbul": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.1.0.tgz", + "integrity": "sha512-uXUVqNUCLa0AH1vuVxzi+MI4RfxEOKt9pBgKwHbgH7st8Kv2P1m+jvWNnektzBh5QShF3ODgKmUFCf38LnVz1g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "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=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "dev": true, + "requires": { + "browser-process-hrtime": "^1.0.0" + } + }, + "w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, + "requires": { + "xml-name-validator": "^3.0.0" + } + }, + "walker": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", + "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=", + "dev": true, + "requires": { + "makeerror": "1.0.x" + } + }, + "webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true + }, + "whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "requires": { + "iconv-lite": "0.4.24" + } + }, + "whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "whatwg-url": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz", + "integrity": "sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^2.0.2", + "webidl-conversions": "^6.1.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "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 + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "ws": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.2.tgz", + "integrity": "sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA==" + }, + "xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", + "dev": true + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "y18n": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", + "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "yargs-unparser": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.4.tgz", + "integrity": "sha512-QxEx9+qEr7jwVM4ngnk95+sKZ5QXm5gx0cL97LDby0SiC8HHoUK0LPBg475JwQcRCqIVfMD8SubCWp1dEgKuwQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "decamelize": "^1.2.0", + "flat": "^5.0.2", + "is-plain-obj": "^1.1.0", + "yargs": "^14.2.3" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "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 + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "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 + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "yargs": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", + "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^15.0.1" + } + }, + "yargs-parser": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz", + "integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "ylru": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.2.1.tgz", + "integrity": "sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==" + } + } +} diff --git a/src/package.json b/src/package.json index f1c67a61e..fbe0b62bf 100644 --- a/src/package.json +++ b/src/package.json @@ -1,17 +1,22 @@ { "name": "@mojaloop/sdk-scheme-adapter", - "version": "11.0.0", + "version": "11.10.1", "description": "An adapter for connecting to Mojaloop API enabled switches.", "main": "index.js", "scripts": { + "audit:resolve": "SHELL=sh resolve-audit --production", + "audit:check": "SHELL=sh check-audit --production", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "jest --ci --reporters=default --reporters=jest-junit --env=node test/unit", + "test": "jest --ci --reporters=default --reporters=jest-junit --env=node test/unit/", "test:int": "jest --ci --reporters=default --reporters=jest-junit --env=node test/integration --forceExit" }, "author": "Matt Kingston, James Bush, ModusBox Inc.", "contributors": [ + "Kamuela Franco ", "Steven Oderayi ", + "Valentin Genev ", + "Shashikant Hirugade ", "Paweł Marzec " ], "license": "Apache-2.0", @@ -23,34 +28,35 @@ ], "dependencies": { "@internal/cache": "file:lib/cache", - "@internal/log": "file:lib/log", + "@internal/check": "file:lib/check", "@internal/model": "file:lib/model", "@internal/randomphrase": "file:lib/randomphrase", "@internal/requests": "file:lib/model/lib/requests", "@internal/router": "file:lib/router", "@internal/shared": "file:lib/model/lib/shared", "@internal/validate": "file:lib/validate", - "@mojaloop/sdk-standard-components": "^10.6.8", - "ajv": "^6.12.2", - "co-body": "^6.0.0", + "@mojaloop/sdk-standard-components": "13.1.0", + "ajv": "^6.12.6", + "co-body": "^6.1.0", "dotenv": "^8.2.0", "env-var": "^6.1.1", - "js-yaml": "^3.13.1", + "js-yaml": "^3.14.1", "koa": "^2.11.0", "koa-body": "^4.1.1", "koa2-oauth-server": "^1.0.0", "node-fetch": "^2.6.0", - "uuidv4": "^6.0.8", - "ws": "^7.3.1" + "uuidv4": "^6.2.6", + "ws": "^7.4.1" }, "devDependencies": { "@types/jest": "^25.2.1", - "eslint": "^7.0.0", - "eslint-config-airbnb-base": "^14.1.0", - "eslint-plugin-import": "^2.20.2", - "jest": "^26.0.1", + "eslint": "^7.15.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-plugin-import": "^2.22.1", + "jest": "^26.6.3", "jest-junit": "^10.0.0", "nock": "^12.0.3", + "npm-audit-resolver": "2.2.0", "openapi-response-validator": "^4.0.0", "redis-mock": "^0.49.0", "supertest": "^4.0.2" diff --git a/src/test/__mocks__/@internal/requests.js b/src/test/__mocks__/@internal/requests.js index aa4bf4ab9..5384281d8 100644 --- a/src/test/__mocks__/@internal/requests.js +++ b/src/test/__mocks__/@internal/requests.js @@ -33,6 +33,7 @@ class MockBackendRequests extends BackendRequests { this.postBulkQuotes = MockBackendRequests.__postBulkQuotes; this.getBulkTransfers = MockBackendRequests.__getBulkTransfers; this.postBulkTransfers = MockBackendRequests.__postBulkTransfers; + this.putTransfersNotification = MockBackendRequests.__putTransfersNotification; } } MockBackendRequests.__getParties = jest.fn(() => Promise.resolve({body: {}})); @@ -46,6 +47,8 @@ MockBackendRequests.__getBulkQuotes = jest.fn(() => Promise.resolve({body: {}})) MockBackendRequests.__postBulkQuotes = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__getBulkTransfers = jest.fn(() => Promise.resolve({body: {}})); MockBackendRequests.__postBulkTransfers = jest.fn(() => Promise.resolve({body: {}})); +MockBackendRequests.__putTransfersNotification = jest.fn(() => Promise.resolve({body: {}})); + class HTTPResponseError extends Error { diff --git a/src/test/__mocks__/@mojaloop/sdk-standard-components.js b/src/test/__mocks__/@mojaloop/sdk-standard-components.js index ca068d4ab..423051730 100644 --- a/src/test/__mocks__/@mojaloop/sdk-standard-components.js +++ b/src/test/__mocks__/@mojaloop/sdk-standard-components.js @@ -10,8 +10,9 @@ 'use strict'; +const assert = require('assert').strict; const util = require('util'); -const { MojaloopRequests, ThirdpartyRequests, Errors, WSO2Auth, Jws } = jest.requireActual('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, ThirdpartyRequests, Errors, WSO2Auth, Jws, Logger } = jest.requireActual('@mojaloop/sdk-standard-components'); class MockMojaloopRequests extends MojaloopRequests { @@ -81,40 +82,42 @@ MockThirdpartyRequests.__postThirdpartyRequestsTransactions = jest.fn(() => Prom class MockIlp { constructor(config) { - console.log('MockIlp constructed'); + assert(config.logger, 'Must supply a logger to Ilp constructor'); + this.logger = config.logger; + this.logger.log('MockIlp constructed'); this.config = config; } calculateFulfil(ilpPacket) { - console.log(`Mock ILP not calculating fulfil from ilp packet ${ilpPacket}`); + this.logger.log(`Mock ILP not calculating fulfil from ilp packet ${ilpPacket}`); return 'mockGeneratedFulfilment'; } calculateConditionFromFulfil(fulfil) { - console.log(`Mock ILP not calculating condition from fulfil ${fulfil}`); + this.logger.log(`Mock ILP not calculating condition from fulfil ${fulfil}`); return 'mockGeneratedCondition'; } validateFulfil(fulfil, condition) { - console.log(`Mock ILP not checking fulfil ${fulfil} against condition ${condition}`); + this.logger.log(`Mock ILP not checking fulfil ${fulfil} against condition ${condition}`); return true; } getResponseIlp(...args) { - console.log(`MockIlp.getResponseIlp called with args: ${util.inspect(args)}`); + this.logger.log(`MockIlp.getResponseIlp called with args: ${util.inspect(args)}`); return MockIlp.__response; } getQuoteResponseIlp(...args) { - console.log(`MockIlp.getQuoteResponseIlp called with args: ${util.inspect(args)}`); + this.logger.log(`MockIlp.getQuoteResponseIlp called with args: ${util.inspect(args)}`); return this.getResponseIlp(...args); } getTransactionObject(...args) { - console.log(`MockIlp.getTrasnactionObject called with args: ${util.inspect(args)}`); + this.logger.log(`MockIlp.getTrasnactionObject called with args: ${util.inspect(args)}`); return MockIlp.__transactionObject; } @@ -142,8 +145,9 @@ MockJwsValidator.__validate = jest.fn(() => true); class MockJwsSigner { constructor(config) { + assert(config.logger, 'Must supply a logger to JWS signer constructor'); this.config = config; - console.log(`MockJwsSigner constructed with config: ${util.inspect(config)}`); + config.logger.log(`MockJwsSigner constructed with config: ${util.inspect(config)}`); } } @@ -158,4 +162,5 @@ module.exports = { }, Errors, WSO2Auth, + Logger, }; diff --git a/src/test/config/integration.env b/src/test/config/integration.env index 850619221..c17f26730 100644 --- a/src/test/config/integration.env +++ b/src/test/config/integration.env @@ -49,12 +49,14 @@ CACHE_SHOULD_EXPIRE=false CACHE_EXPIRY_SECONDS=3600 # SWITCH ENDPOINT -# The option 'PEER_ENDPOINT' has no effect if the remaining three options 'ALS_ENDPOINT', 'QUOTES_ENDPOINT', 'TRANSFERS_ENDPOINT' are specified. +# The option 'PEER_ENDPOINT' has no effect if the remaining options 'ALS_ENDPOINT', 'QUOTES_ENDPOINT', +# 'BULK_QUOTES_ENDPOINT', 'TRANSFERS_ENDPOINT', 'BULK_TRANSFERS_ENDPOINT', 'TRANSACTION_REQUESTS_ENDPOINT' are specified. PEER_ENDPOINT=172.17.0.3:4000 #ALS_ENDPOINT=account-lookup-service.local #QUOTES_ENDPOINT=quoting-service.local #TRANSFERS_ENDPOINT=ml-api-adapter.local #BULK_TRANSFERS_ENDPOINT=bulk-api-adapter.local +#TRANSACTION_REQUESTS_ENDPOINT=transaction-requests-service.local # BACKEND ENDPOINT BACKEND_ENDPOINT=172.17.0.5:4000 diff --git a/src/test/integration/lib/cache.test.js b/src/test/integration/lib/cache.test.js index 6b3d31729..d213865f6 100644 --- a/src/test/integration/lib/cache.test.js +++ b/src/test/integration/lib/cache.test.js @@ -33,7 +33,7 @@ jest.dontMock('redis'); const Cache = require('@internal/cache'); -const { Logger } = require('@internal/log'); +const { Logger } = require('@mojaloop/sdk-standard-components'); const defaultCacheConfig = { host: 'redis', @@ -42,13 +42,11 @@ const defaultCacheConfig = { }; const createCache = async (config) => { - const transports = []; - config.logger = new Logger({ + config.logger = new Logger.Logger({ context: { app: 'mojaloop-sdk-inboundCache' }, - space: 4, - transports + stringify: Logger.buildStringify({ space: 4 }), }); const cache = new Cache(config); await cache.connect(); @@ -66,8 +64,8 @@ describe('Cache', () => { // Act await cache.set('keyA', JSON.stringify(value)); const result = await cache.get('keyA'); - + // Assert expect(result).toStrictEqual(value); }); -}); \ No newline at end of file +}); diff --git a/src/test/unit/InboundServer.test.js b/src/test/unit/InboundServer.test.js index 613245a0d..9d419e108 100644 --- a/src/test/unit/InboundServer.test.js +++ b/src/test/unit/InboundServer.test.js @@ -22,7 +22,8 @@ jest.mock('@internal/cache'); jest.mock('@mojaloop/sdk-standard-components'); jest.mock('@internal/requests'); -const { Jws } = require('@mojaloop/sdk-standard-components'); +const Cache = require('@internal/cache'); +const { Jws, Logger } = require('@mojaloop/sdk-standard-components'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -43,10 +44,11 @@ describe('Inbound Server', () => { async function testPartiesJwsValidation(validateInboundJws, validateInboundPutPartiesJws, expectedValidationCalls) { serverConfig.validateInboundJws = validateInboundJws; serverConfig.validateInboundPutPartiesJws = validateInboundPutPartiesJws; - const svr = new InboundServer(serverConfig); - const req = supertest(await svr.setupApi()); + const logger = new Logger.Logger({ stringify: () => '' }); + const cache = new Cache({ ...serverConfig.cacheConfig, logger: logger.push({ component: 'cache' }) }); + const svr = new InboundServer(serverConfig, logger, cache); await svr.start(); - await req + await supertest(svr._server) .put('/parties/MSISDN/123456789') .send(putPartiesBody) .set(commonHttpHeaders) @@ -79,10 +81,11 @@ describe('Inbound Server', () => { async function testQuotesJwsValidation(validateInboundJws, validateInboundPutPartiesJws, expectedValidationCalls) { serverConfig.validateInboundJws = validateInboundJws; serverConfig.validateInboundPutPartiesJws = validateInboundPutPartiesJws; - const svr = new InboundServer(serverConfig); - const req = supertest(await svr.setupApi()); + const logger = new Logger.Logger({ stringify: () => '' }); + const cache = new Cache({ ...serverConfig.cacheConfig, logger: logger.push({ component: 'cache' }) }); + const svr = new InboundServer(serverConfig, logger, cache); await svr.start(); - await req + await supertest(svr._server) .post('/quotes') .send(postQuotesBody) .set(commonHttpHeaders) @@ -110,10 +113,11 @@ describe('Inbound Server', () => { async function testParticipantsJwsValidation(validateInboundJws, validateInboundPutPartiesJws, expectedValidationCalls) { serverConfig.validateInboundJws = validateInboundJws; serverConfig.validateInboundPutPartiesJws = validateInboundPutPartiesJws; - const svr = new InboundServer(serverConfig); - const req = supertest(await svr.setupApi()); + const logger = new Logger.Logger({ stringify: () => '' }); + const cache = new Cache({ ...serverConfig.cacheConfig, logger: logger.push({ component: 'cache' }) }); + const svr = new InboundServer(serverConfig, logger, cache); await svr.start(); - await req + await supertest(svr._server) .put('/participants/00000000-0000-1000-a000-000000000002') .send(putParticipantsBody) .set(commonHttpHeaders) @@ -153,9 +157,11 @@ describe('Inbound Server', () => { }); async function testTlsServer(enableTls) { - defConfig.tls.inbound.mutualTLS.enabled = enableTls; - const server = new InboundServer(defConfig); - await server.setupApi(); + defConfig.inbound.tls.mutualTLS.enabled = enableTls; + const logger = new Logger.Logger({ stringify: () => '' }); + const cache = new Cache({ ...defConfig.cacheConfig, logger: logger.push({ component: 'cache' }) }); + const server = new InboundServer(defConfig, logger, cache); + await server.start(); if (enableTls) { expect(httpsServerSpy).toHaveBeenCalled(); expect(httpServerSpy).not.toHaveBeenCalled(); @@ -163,6 +169,7 @@ describe('Inbound Server', () => { expect(httpsServerSpy).not.toHaveBeenCalled(); expect(httpServerSpy).toHaveBeenCalled(); } + await server.stop(); } test('Inbound server should use HTTPS if inbound mTLS enabled', () => @@ -184,8 +191,9 @@ describe('Inbound Server', () => { const mockFilePath = path.join(keysDir, 'mojaloop-sdk.pem'); fs.writeFileSync(mockFilePath, 'foo-key'); serverConfig.jwsVerificationKeysDirectory = keysDir; - svr = new InboundServer(serverConfig); - await svr.setupApi(); + const logger = new Logger.Logger({ stringify: () => '' }); + const cache = new Cache({ ...serverConfig.cacheConfig, logger: logger.push({ component: 'cache' }) }); + svr = new InboundServer(serverConfig, logger, cache); await svr.start(); }); diff --git a/src/test/unit/TestServer.test.js b/src/test/unit/TestServer.test.js index 2994c340e..eec764629 100644 --- a/src/test/unit/TestServer.test.js +++ b/src/test/unit/TestServer.test.js @@ -18,8 +18,9 @@ const postQuotesBody = require('./data/postQuotesBody'); const putParticipantsBody = require('./data/putParticipantsBody'); const commonHttpHeaders = require('./data/commonHttpHeaders'); const WebSocket = require('ws'); +const { Logger } = require('@mojaloop/sdk-standard-components'); -const cache = require('@internal/cache'); +const Cache = require('@internal/cache'); jest.mock('@internal/cache'); jest.mock('@mojaloop/sdk-standard-components'); jest.mock('@internal/requests'); @@ -37,40 +38,41 @@ const createWsClient = async (port, path) => { }; describe('Test Server', () => { - let testServer, inboundServer, inboundReq, testReq, serverConfig, inboundCache, testCache, - wsClients; + let testServer, inboundServer, inboundReq, testReq, serverConfig, wsClients, testServerPort, + logger, cache; beforeEach(async () => { - cache.mockClear(); + Cache.mockClear(); + + logger = new Logger.Logger({ stringify: () => '' }); serverConfig = { ...JSON.parse(JSON.stringify(defaultConfig)), enableTestFeatures: true, }; + cache = new Cache({ ...serverConfig.cacheConfig, logger: logger.push({ component: 'cache' }), enableTestFeatures: true }); - testServer = new TestServer(serverConfig); - await testServer.setupApi(); + testServer = new TestServer({ logger, cache }); await testServer.start(); + testServerPort = testServer._server.address().port; expect(testServer._server.listening).toBe(true); testReq = supertest.agent(testServer._server); - testCache = cache.mock.instances[0]; - inboundServer = new InboundServer(serverConfig); - inboundReq = supertest(await inboundServer.setupApi()); + inboundServer = new InboundServer(serverConfig, logger, cache); await inboundServer.start(); - inboundCache = cache.mock.instances[1]; + inboundReq = supertest(inboundServer._server); wsClients = { - root: await createWsClient(serverConfig.testPort, '/'), - callbacks: await createWsClient(serverConfig.testPort, '/callbacks'), - requests: await createWsClient(serverConfig.testPort, '/requests'), + root: await createWsClient(testServerPort, '/'), + callbacks: await createWsClient(testServerPort, '/callbacks'), + requests: await createWsClient(testServerPort, '/requests'), }; expect(Object.values(wsClients).every((cli) => cli.readyState === WebSocket.OPEN)).toBe(true); - expect(testServer._wsClients.size).toBeGreaterThan(0); + expect(testServer._wsapi._wsClients.size).toBeGreaterThan(0); - expect(cache).toHaveBeenCalledTimes(2); + expect(Cache).toHaveBeenCalledTimes(1); }); afterEach(async () => { @@ -84,9 +86,9 @@ describe('Test Server', () => { // TODO: check this happens correctly with top-level server if possible? test('Inbound server and Test server construct cache with same parameters when provided same config', async () => { - expect(cache).toHaveBeenCalledTimes(2); - const testArgs = { ...cache.mock.calls[0][0], logger: expect.anything() }; - expect(cache).toHaveBeenNthCalledWith(2, testArgs); + expect(Cache).toHaveBeenCalledTimes(1); + const testArgs = { ...Cache.mock.calls[0][0], logger: expect.anything() }; + expect(Cache).toHaveBeenNthCalledWith(1, testArgs); }); test('Health check', async () => { @@ -108,7 +110,7 @@ describe('Test Server', () => { await testReq.get(`/callbacks/${MSISDN}`); - expect(inboundCache.set.mock.calls[0][0]).toEqual(testCache.get.mock.calls[0][0]); + expect(cache.set.mock.calls[0][0]).toEqual(cache.get.mock.calls[0][0]); }); test('POST /quotes requests cache get and set use same value', async () => { @@ -122,7 +124,7 @@ describe('Test Server', () => { await testReq.get(`/requests/${postQuotesBody.quoteId}`); - expect(inboundCache.set.mock.calls[0][0]).toEqual(testCache.get.mock.calls[0][0]); + expect(cache.set.mock.calls[0][0]).toEqual(cache.get.mock.calls[0][0]); }); test('PUT /participants callbacks cache get and set use same value', async () => { @@ -138,22 +140,17 @@ describe('Test Server', () => { await testReq.get(`/callbacks/${participantId}`); - expect(inboundCache.set.mock.calls[0][0]).toEqual(testCache.get.mock.calls[0][0]); + expect(cache.set.mock.calls[0][0]).toEqual(cache.get.mock.calls[0][0]); }); test('Subscribes to the keyevent set notification', async () => { - expect(testServer._cache.subscribe).toBeCalledTimes(1); - expect(testServer._cache.subscribe).toHaveBeenCalledWith( - testServer._cache.EVENT_SET, + expect(testServer._wsapi._cache.subscribe).toBeCalledTimes(1); + expect(testServer._wsapi._cache.subscribe).toHaveBeenCalledWith( + testServer._wsapi._cache.EVENT_SET, expect.any(Function), ); }); - test('Configures cache correctly', async () => { - expect(testServer._cache.setTestMode).toBeCalledTimes(1); - expect(testServer._cache.setTestMode).toHaveBeenCalledWith(true); - }); - test('WebSocket /callbacks and / endpoint triggers send to client when callback received to inbound server', async () => { const participantId = '00000000-0000-1000-a000-000000000002'; @@ -165,7 +162,7 @@ describe('Test Server', () => { }; const putParticipantWsClient = await createWsClient( - serverConfig.testPort, + testServerPort, `/callbacks/${participantId}` ); @@ -181,13 +178,13 @@ describe('Test Server', () => { // get the callback function that the test server subscribed with, and mock the cache by // calling the callback when the inbound server sets a key in the cache. - const callback = testServer._cache.subscribe.mock.calls[0][1]; - inboundServer._cache.set = jest.fn(async (key) => await callback( - inboundServer._cache.EVENT_SET, + const callback = testServer._wsapi._cache.subscribe.mock.calls[0][1]; + inboundServer._api._cache.set = jest.fn(async (key) => await callback( + inboundServer._api._cache.EVENT_SET, key, 1, )); - testServer._cache.get = jest.fn(() => ({ + testServer._wsapi._cache.get = jest.fn(() => ({ data: putParticipantsBody, headers, })); @@ -197,18 +194,18 @@ describe('Test Server', () => { .send(putParticipantsBody) .set(headers); - expect(inboundServer._cache.set).toHaveBeenCalledTimes(1); - expect(inboundServer._cache.set).toHaveBeenCalledWith( - `${testServer._cache.CALLBACK_PREFIX}${participantId}`, + expect(inboundServer._api._cache.set).toHaveBeenCalledTimes(1); + expect(inboundServer._api._cache.set).toHaveBeenCalledWith( + `${testServer._wsapi._cache.CALLBACK_PREFIX}${participantId}`, { data: putParticipantsBody, headers: expect.objectContaining(headers), } ); - expect(testServer._cache.get).toHaveBeenCalledTimes(1); - expect(testServer._cache.get).toHaveBeenCalledWith( - `${testServer._cache.CALLBACK_PREFIX}${participantId}` + expect(testServer._wsapi._cache.get).toHaveBeenCalledTimes(1); + expect(testServer._wsapi._cache.get).toHaveBeenCalledWith( + `${testServer._wsapi._cache.CALLBACK_PREFIX}${participantId}` ); const expectedMessage = { @@ -236,7 +233,7 @@ describe('Test Server', () => { }; const postQuoteWsClient = await createWsClient( - serverConfig.testPort, + testServerPort, `/requests/${postQuotesBody.quoteId}` ); @@ -252,13 +249,13 @@ describe('Test Server', () => { // get the callback function that the test server subscribed with, and mock the cache by // calling the callback when the inbound server sets a key in the cache. - const callback = testServer._cache.subscribe.mock.calls[0][1]; - inboundServer._cache.set = jest.fn(async (key) => await callback( - inboundServer._cache.EVENT_SET, + const callback = testServer._wsapi._cache.subscribe.mock.calls[0][1]; + inboundServer._api._cache.set = jest.fn(async (key) => await callback( + inboundServer._api._cache.EVENT_SET, key, 1, )); - testServer._cache.get = jest.fn(() => ({ + testServer._wsapi._cache.get = jest.fn(() => ({ data: postQuotesBody, headers, })); @@ -268,19 +265,19 @@ describe('Test Server', () => { .send(postQuotesBody) .set(headers); - // Called once for the quote request, once for the fulfilment - expect(inboundServer._cache.set).toHaveBeenCalledTimes(2); - expect(inboundServer._cache.set).toHaveBeenCalledWith( - `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}`, + // Called twice for the quote request, once for the fulfilment + expect(inboundServer._api._cache.set).toHaveBeenCalledTimes(3); + expect(inboundServer._api._cache.set).toHaveBeenCalledWith( + `${testServer._wsapi._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}`, { data: postQuotesBody, headers: expect.objectContaining(headers), } ); - expect(testServer._cache.get).toHaveBeenCalledTimes(1); - expect(testServer._cache.get).toHaveBeenCalledWith( - `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}` + expect(testServer._wsapi._cache.get).toHaveBeenCalledTimes(1); + expect(testServer._wsapi._cache.get).toHaveBeenCalledWith( + `${testServer._wsapi._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}` ); const expectedMessage = { @@ -313,13 +310,13 @@ describe('Test Server', () => { // get the callback function that the test server subscribed with, and mock the cache by // calling the callback when the inbound server sets a key in the cache. - const callback = testServer._cache.subscribe.mock.calls[0][1]; - inboundServer._cache.set = jest.fn(async (key) => await callback( - inboundServer._cache.EVENT_SET, + const callback = testServer._wsapi._cache.subscribe.mock.calls[0][1]; + inboundServer._api._cache.set = jest.fn(async (key) => await callback( + inboundServer._api._cache.EVENT_SET, key, 1, )); - testServer._cache.get = jest.fn(() => ({ + testServer._wsapi._cache.get = jest.fn(() => ({ data: postQuotesBody, headers: quoteRequestHeaders, })); @@ -329,19 +326,19 @@ describe('Test Server', () => { .send(postQuotesBody) .set(quoteRequestHeaders); - // Called once for the quote request, once for the fulfilment - expect(inboundServer._cache.set).toHaveBeenCalledTimes(2); - expect(inboundServer._cache.set).toHaveBeenCalledWith( - `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}`, + // Called twice for the quote request, once for the fulfilment + expect(inboundServer._api._cache.set).toHaveBeenCalledTimes(3); + expect(inboundServer._api._cache.set).toHaveBeenCalledWith( + `${testServer._wsapi._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}`, { data: postQuotesBody, headers: expect.objectContaining(quoteRequestHeaders), } ); - expect(testServer._cache.get).toHaveBeenCalledTimes(1); - expect(testServer._cache.get).toHaveBeenCalledWith( - `${testServer._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}` + expect(testServer._wsapi._cache.get).toHaveBeenCalledTimes(1); + expect(testServer._wsapi._cache.get).toHaveBeenCalledWith( + `${testServer._wsapi._cache.REQUEST_PREFIX}${postQuotesBody.quoteId}` ); const expectedMessage = { @@ -369,11 +366,11 @@ describe('Test Server', () => { .send(putParticipantsBody) .set(putParticipantsHeaders); - // Called twice for the quote request earlier in this test, another time now for the put + // Called thrice for the quote request earlier in this test, another time now for the put // participants request - expect(inboundServer._cache.set).toHaveBeenCalledTimes(3); - expect(inboundServer._cache.set.mock.calls[2]).toEqual([ - `${testServer._cache.CALLBACK_PREFIX}${participantId}`, + expect(inboundServer._api._cache.set).toHaveBeenCalledTimes(4); + expect(inboundServer._api._cache.set.mock.calls[3]).toEqual([ + `${testServer._wsapi._cache.CALLBACK_PREFIX}${participantId}`, { data: putParticipantsBody, headers: expect.objectContaining(putParticipantsHeaders), @@ -382,9 +379,9 @@ describe('Test Server', () => { // Called once for the quote request earlier in this test, another time now for the // participants callback - expect(testServer._cache.get).toHaveBeenCalledTimes(2); - expect(testServer._cache.get.mock.calls[1]).toEqual([ - `${testServer._cache.CALLBACK_PREFIX}${participantId}` + expect(testServer._wsapi._cache.get).toHaveBeenCalledTimes(2); + expect(testServer._wsapi._cache.get.mock.calls[1]).toEqual([ + `${testServer._wsapi._cache.CALLBACK_PREFIX}${participantId}` ]); }); }); diff --git a/src/test/unit/api/accounts/utils.js b/src/test/unit/api/accounts/utils.js index 95320c1ff..4eedf835a 100644 --- a/src/test/unit/api/accounts/utils.js +++ b/src/test/unit/api/accounts/utils.js @@ -53,7 +53,6 @@ function createPostAccountsTester({ reqInbound, reqOutbound, apiSpecsOutbound }) const responseValidator = new OpenAPIResponseValidator(apiSpecsOutbound.paths['/accounts'].post); const err = responseValidator.validateResponse(responseCode, body); if (err) { - console.log(body); throw err; } await pendingRequest; diff --git a/src/test/unit/api/transfers/utils.js b/src/test/unit/api/transfers/utils.js index 40b4b9ad4..416b6435e 100644 --- a/src/test/unit/api/transfers/utils.js +++ b/src/test/unit/api/transfers/utils.js @@ -1,7 +1,7 @@ const nock = require('nock'); const OpenAPIResponseValidator = require('openapi-response-validator').default; -const { Logger } = require('@internal/log'); +const { Logger } = require('@mojaloop/sdk-standard-components'); const defaultConfig = require('../../data/defaultConfig'); const postTransfersSimpleBody = require('./data/postTransfersSimpleBody'); @@ -49,11 +49,14 @@ function createGetTransfersTester({ reqInbound, reqOutbound, apiSpecsOutbound }) const res = await reqOutbound.get(`/transfers/${TRANSFER_ID}`); const {body} = res; expect(res.statusCode).toEqual(responseCode); + delete body.initiatedTimestamp; + if (body.transferState) { + delete body.transferState.initiatedTimestamp; + } expect(body).toEqual(responseBody); const responseValidator = new OpenAPIResponseValidator(apiSpecsOutbound.paths['/transfers/{transferId}'].get); const err = responseValidator.validateResponse(responseCode, body); if (err) { - console.log(body); throw err; } }; @@ -70,12 +73,7 @@ function createGetTransfersTester({ reqInbound, reqOutbound, apiSpecsOutbound }) function createPostTransfersTester( { requestValidatorInbound, reqInbound, reqOutbound, apiSpecsOutbound }) { - const logTransports = [() => {}]; - const logger = new Logger({ - context: { app: 'outbound-model-unit-tests' }, - space: 4, - transports: logTransports, - }); + const logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests' }, stringify: () => '' }); /** * @@ -159,11 +157,14 @@ function createPostTransfersTester( const res = await reqOutbound.post('/transfers').send(postTransfersSimpleBody); const {body} = res; expect(res.statusCode).toEqual(responseCode); + delete body.initiatedTimestamp; + if (body.transferState) { + delete body.transferState.initiatedTimestamp; + } expect(body).toEqual(responseBody); const responseValidator = new OpenAPIResponseValidator(apiSpecsOutbound.paths['/transfers'].post); const err = responseValidator.validateResponse(responseCode, body); if (err) { - console.log(body); throw err; } await pendingRequest; diff --git a/src/test/unit/api/utils.js b/src/test/unit/api/utils.js index b6b61ad01..c005c26ac 100644 --- a/src/test/unit/api/utils.js +++ b/src/test/unit/api/utils.js @@ -6,6 +6,8 @@ const Validate = require('@internal/validate'); const InboundServer = require('../../../InboundServer'); const OutboundServer = require('../../../OutboundServer'); +const { Logger } = require('@mojaloop/sdk-standard-components'); +const Cache = require('@internal/cache'); /** * Get OpenAPI spec and Validator for specified server @@ -37,15 +39,21 @@ const createValidators = async () => { }; const createTestServers = async (config) => { + const logger = new Logger.Logger({ stringify: () => '' }); const defConfig = JSON.parse(JSON.stringify(config)); + const cache = new Cache({ + ...defConfig.cacheConfig, + logger: logger.push({ component: 'cache' }) + }); + await cache.connect(); defConfig.requestProcessingTimeoutSeconds = 2; - const serverOutbound = new OutboundServer(defConfig); - const reqOutbound = supertest(await serverOutbound.setupApi()); + const serverOutbound = new OutboundServer(defConfig, logger, cache); await serverOutbound.start(); + const reqOutbound = supertest(serverOutbound._server); - const serverInbound = new InboundServer(defConfig); - const reqInbound = supertest(await serverInbound.setupApi()); + const serverInbound = new InboundServer(defConfig, logger, cache); await serverInbound.start(); + const reqInbound = supertest(serverInbound._server); return { serverOutbound, diff --git a/src/test/unit/config.test.js b/src/test/unit/config.test.js index dd97c4968..ffdb69ae8 100644 --- a/src/test/unit/config.test.js +++ b/src/test/unit/config.test.js @@ -67,7 +67,7 @@ describe('config', () => { fs.writeFileSync(cert, certContent); process.env.IN_SERVER_CERT_PATH = cert; const config = require('../../config'); - const content = config.tls.inbound.creds.cert.toString(); + const content = config.inbound.tls.creds.cert.toString(); expect(content).toBe(certContent); }); @@ -83,7 +83,7 @@ describe('config', () => { certs.forEach((cert, index) => fs.writeFileSync(cert, certContent[index])); process.env.IN_CA_CERT_PATH = certs.join(','); const config = require('../../config'); - const content = config.tls.inbound.creds.ca.map(ca => ca.toString()); + const content = config.inbound.tls.creds.ca.map(ca => ca.toString()); expect(content).toStrictEqual(certContent); }); @@ -93,4 +93,27 @@ describe('config', () => { const proxyConfig = require('./data/testFile'); expect(config.proxyConfig).toEqual(proxyConfig); }); + + it('should transform correctly resources versions to config', () => { + + const resourceVersions = { + resourceOneName: { + acceptVersion: '1', + contentVersion: '1.0', + }, + resourceTwoName: { + acceptVersion: '1', + contentVersion: '1.1', + }, + + }; + const parseResourceVersion = require('../../config').__parseResourceVersion; + expect(parseResourceVersion('resourceOneName=1.0,resourceTwoName=1.1')).toEqual(resourceVersions); + }); + + it('should throw an err if the resource string is not correctly formed', () => { + const parseResourceVersion = require('../../config').__parseResourceVersion; + expect(() => parseResourceVersion('resourceOneName=1.0;resourceTwoName=1.1')).toThrowError(new Error('Resource versions format should be in format: "resouceOneName=1.0,resourceTwoName=1.1"')); + }); + }); diff --git a/src/test/unit/data/defaultConfig.json b/src/test/unit/data/defaultConfig.json index 96f4e7f66..9c972deac 100644 --- a/src/test/unit/data/defaultConfig.json +++ b/src/test/unit/data/defaultConfig.json @@ -1,36 +1,26 @@ { - "inboundPort": 4000, - "outboundPort": 4001, - "testPort": 4002, - "peerEndpoint": "172.17.0.2:3001", - "backendEndpoint": "172.17.0.2:3001", - "alsEndpoint": "127.0.0.1:6500", - "dfspId": "mojaloop-sdk", - "ilpSecret": "mojaloop-sdk", - "checkIlp": false, - "expirySeconds": 60, - "requestProcessingTimeoutSeconds": 30, - "autoAcceptQuotes": true, - "autoAcceptParty": true, - "useQuoteSourceFSPAsTransferPayeeFSP": false, - "tls": { - "test": { + "test": { + "tls": { "mutualTLS": { "enabled": false }, "creds": { "ca": null, "cert": null, "key": null } - }, - "inbound": { + } + }, + "inbound": { + "tls": { "mutualTLS": { "enabled": false }, "creds": { "ca": null, "cert": null, "key": null } - }, - "outbound": { + } + }, + "outbound": { + "tls": { "mutualTLS": { "enabled": false }, "creds": { "ca": null, @@ -39,6 +29,19 @@ } } }, + "peerEndpoint": "172.17.0.2:3001", + "backendEndpoint": "172.17.0.2:3001", + "alsEndpoint": "127.0.0.1:6500", + "dfspId": "mojaloop-sdk", + "ilpSecret": "mojaloop-sdk", + "checkIlp": false, + "expirySeconds": 60, + "requestProcessingTimeoutSeconds": 30, + "autoAcceptQuotes": true, + "autoAcceptParty": true, + "useQuoteSourceFSPAsTransferPayeeFSP": false, + "tls": { + }, "validateInboundJws": false, "validateInboundPutPartiesJws": false, "jwsSign": false, @@ -51,13 +54,14 @@ }, "enableTestFeatures": false, "testingDisableWSO2AuthStart": true, - "testingDisableServerStart": true, "oauthTestServer": { "enabled": false, "listenPort": 6000 }, - "wso2Auth": { - "refreshSeconds": 3600 + "wso2": { + "auth": { + "refreshSeconds": 3600 + } }, "rejectExpiredQuoteResponses": false, "rejectExpiredTransferFulfils": false, diff --git a/src/test/unit/inboundApi/handlers.test.js b/src/test/unit/inboundApi/handlers.test.js index 0c7c354b6..8c1611586 100644 --- a/src/test/unit/inboundApi/handlers.test.js +++ b/src/test/unit/inboundApi/handlers.test.js @@ -19,7 +19,9 @@ const Model = require('@internal/model').InboundTransfersModel; const mockArguments = require('./data/mockArguments'); const mockTransactionRequestData = require('./data/mockTransactionRequest'); const mockAuthorizationArguments = require('../lib/model/data/mockAuthorizationArguments.json'); +// TODO: decide between logger implementations const mockLogger = require('../mockLogger'); +const { Logger } = require('@mojaloop/sdk-standard-components'); const AuthorizationsModel = require('@internal/model').OutboundAuthorizationsModel; const ThirdpartyTrxnModelIn = require('@internal/model').InboundThirdpartyTransactionModel; const ThirdpartyTrxnModelOut = require('@internal/model').OutboundThirdpartyTransactionModel; @@ -69,6 +71,44 @@ describe('Inbound API handlers:', () => { }); + }); + + describe('GET /quotes', () => { + + let mockContext; + + beforeEach(() => { + mockContext = { + request: { + headers: { + 'fspiop-source': 'foo' + } + }, + response: {}, + state: { + conf: {}, + path: { + params: { + 'ID': '1234567890' + } + }, + logger: new Logger.Logger({ context: { app: 'inbound-handlers-unit-test' }, stringify: () => '' }), + } + }; + + }); + + test('calls `model.getQuoteRequest` with the expected arguments.', async () => { + const getQuoteRequestSpy = jest.spyOn(Model.prototype, 'getQuoteRequest'); + + await expect(handlers['/quotes/{ID}'].get(mockContext)).resolves.toBe(undefined); + + expect(getQuoteRequestSpy).toHaveBeenCalledTimes(1); + expect(getQuoteRequestSpy.mock.calls[0][1]).toBe(mockContext.request.headers['fspiop-source']); + + }); + + }); describe('POST /bulkQuotes', () => { diff --git a/src/test/unit/index.test.js b/src/test/unit/index.test.js index 1cbbf735c..bcc435094 100644 --- a/src/test/unit/index.test.js +++ b/src/test/unit/index.test.js @@ -10,6 +10,9 @@ 'use strict'; +const { Logger } = require('@mojaloop/sdk-standard-components'); +const defaultConfig = require('./data/defaultConfig'); + jest.mock('dotenv', () => ({ config: jest.fn() })); @@ -21,8 +24,25 @@ process.env.CACHE_PORT = '6379'; const index = require('../../index.js'); - describe('index.js', () => { + test('WSO2 error events in OutboundServer propagate to top-level server', () => { + const logger = new Logger.Logger({ stringify: () => '' }); + const svr = new index.Server(defaultConfig, logger); + const cb = jest.fn(); + svr.on('error', cb); + svr.outboundServer._api._wso2.auth.emit('error', 'msg'); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('WSO2 error events in InboundServer propagate to top-level server', () => { + const logger = new Logger.Logger({ stringify: () => '' }); + const svr = new index.Server(defaultConfig, logger); + const cb = jest.fn(); + svr.on('error', cb); + svr.inboundServer._api._wso2.auth.emit('error', 'msg'); + expect(cb).toHaveBeenCalledTimes(1); + }); + test('Exports expected modules', () => { expect(typeof(index.Server)).toBe('function'); expect(typeof(index.InboundServerMiddleware)).toBe('object'); @@ -30,7 +50,6 @@ describe('index.js', () => { expect(typeof(index.Router)).toBe('function'); expect(typeof(index.Validate)).toBe('function'); expect(typeof(index.RandomPhrase)).toBe('function'); - expect(typeof(index.Log)).toBe('object'); expect(typeof(index.Cache)).toBe('function'); }); }); diff --git a/src/test/unit/lib/cache/cache.test.js b/src/test/unit/lib/cache/cache.test.js index eb657a9ff..8c7bb5921 100644 --- a/src/test/unit/lib/cache/cache.test.js +++ b/src/test/unit/lib/cache/cache.test.js @@ -13,11 +13,10 @@ jest.mock('redis'); const Cache = require('@internal/cache'); -const { Logger } = require('@internal/log'); +const { Logger } = require('@mojaloop/sdk-standard-components'); const createCache = async() => { - const logTransports = [() => {}]; - const logger = new Logger({ context: { app: 'model-unit-tests-cache' }, space: 4, transports: logTransports }); + const logger = new Logger.Logger({ context: { app: 'model-unit-tests-cache' }, stringify: () => '' }); const cache = new Cache({ host: 'dummyhost', port: 1234, @@ -59,9 +58,8 @@ describe('Cache', () => { // create a promise that only gets resoled if the subscription gets the // correct message const cb1Promise = new Promise((resolve) => { - const mockCb1 = jest.fn((cn, msg, subId) => { + const mockCb1 = jest.fn((cn, msg) => { expect(cn).toBe(chan1); - console.log(`callback on subId: ${subId}`); const value = JSON.parse(msg); // check we got the expected message @@ -84,9 +82,8 @@ describe('Cache', () => { // create a second promise that only gets resoled if the second subscription gets the // correct message const cb2Promise = new Promise((resolve) => { - const mockCb2 = jest.fn((cn, msg, subId) => { + const mockCb2 = jest.fn((cn, msg) => { expect(cn).toBe(chan2); - console.log(`callback on subId: ${subId}`); // check we got the expected message const value = JSON.parse(msg); diff --git a/src/test/unit/lib/log/log.test.js b/src/test/unit/lib/log/log.test.js deleted file mode 100644 index ece7a6222..000000000 --- a/src/test/unit/lib/log/log.test.js +++ /dev/null @@ -1,76 +0,0 @@ -/************************************************************************** - * (C) Copyright ModusBox Inc. 2019 - All rights reserved. * - * * - * This file is made available under the terms of the license agreement * - * specified in the corresponding source code repository. * - * * - * ORIGINAL AUTHOR: * - * James Bush - james.bush@modusbox.com * - **************************************************************************/ - -'use strict'; - - -const { Logger, Transports } = require('@internal/log'); - - - -describe('Logger', () => { - - test('logs non-circular without throwing', async () => { - const transports = await Promise.all([Transports.consoleDir()]); - - let logger = new Logger({ - context: { - app: 'test' - }, - space: 4, - transports, - }); - - const testOb = { - a: 'test', - b: 123, - c: [1, 2, 3] - }; - - try { - logger = logger.push(testOb); - await logger.log('This is a test'); - } - catch(e) { - expect(e).toBe(undefined); - } - }); - - - test('logs circular without throwing', async () => { - const transports = await Promise.all([Transports.consoleDir()]); - - let logger = new Logger({ - context: { - app: 'test' - }, - space: 4, - transports, - }); - - const testOb = { - a: 'test', - b: 123, - c: [1, 2, 3] - }; - - // create a circular reference in testOb - testOb.d = testOb; - - try { - logger = logger.push(testOb); - await logger.log('This is a test'); - } - catch(e) { - expect(e).toBe(undefined); - } - }); - -}); diff --git a/src/test/unit/lib/model/AccountsModel.test.js b/src/test/unit/lib/model/AccountsModel.test.js index e6ebb44b8..66b0c70ad 100644 --- a/src/test/unit/lib/model/AccountsModel.test.js +++ b/src/test/unit/lib/model/AccountsModel.test.js @@ -15,11 +15,10 @@ jest.mock('@mojaloop/sdk-standard-components'); jest.mock('redis'); const Cache = require('@internal/cache'); -const { Logger } = require('@internal/log'); const { AccountsModel } = require('@internal/model'); const StateMachine = require('javascript-state-machine'); -const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, Logger } = require('@mojaloop/sdk-standard-components'); const defaultConfig = require('./data/defaultConfig'); const transferRequest = require('./data/transferRequest'); @@ -65,6 +64,7 @@ describe('AccountsModel', () => { const model = new AccountsModel({ ...defaultConfig, + tls: defaultConfig.outbound.tls, cache, logger, }); @@ -77,8 +77,6 @@ describe('AccountsModel', () => { // wait for the model to reach a terminal state const result = await model.run(); - // console.log(`Accounts creation result: ${util.inspect(result)}`); - const expectedRequestsCount = currencies.length * (Math.floor(count / MAX_ITEMS_PER_REQUEST) + ((count % MAX_ITEMS_PER_REQUEST) ? 1 : 0)); expect(MojaloopRequests.__postParticipants).toHaveBeenCalledTimes(expectedRequestsCount); @@ -88,8 +86,7 @@ describe('AccountsModel', () => { } beforeAll(() => { - const logTransports = [() => {}]; - logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests-cache' }, stringify: () => '' }); }); beforeEach(async () => { @@ -108,6 +105,7 @@ describe('AccountsModel', () => { test('initializes to starting state', async () => { const model = new AccountsModel({ ...defaultConfig, + tls: defaultConfig.outbound.tls, cache, logger, }); diff --git a/src/test/unit/lib/model/InboundTransfersModel.test.js b/src/test/unit/lib/model/InboundTransfersModel.test.js index 0d4110a48..8102a1abd 100644 --- a/src/test/unit/lib/model/InboundTransfersModel.test.js +++ b/src/test/unit/lib/model/InboundTransfersModel.test.js @@ -14,11 +14,10 @@ jest.mock('@mojaloop/sdk-standard-components'); jest.mock('redis'); const defaultConfig = require('./data/defaultConfig'); -const { Logger, Transports } = require('@internal/log'); const Model = require('@internal/model').InboundTransfersModel; const mockArguments = require('./data/mockArguments'); const mockTxnReqquestsArguments = require('./data/mockTxnRequestsArguments'); -const { MojaloopRequests, Ilp } = require('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, Ilp, Logger } = require('@mojaloop/sdk-standard-components'); const { BackendRequests, HTTPResponseError } = require('@internal/requests'); const Cache = require('@internal/cache'); @@ -26,6 +25,7 @@ const getTransfersBackendResponse = require('./data/getTransfersBackendResponse' const getTransfersMojaloopResponse = require('./data/getTransfersMojaloopResponse'); const getBulkTransfersBackendResponse = require('./data/getBulkTransfersBackendResponse'); const getBulkTransfersMojaloopResponse = require('./data/getBulkTransfersMojaloopResponse'); +const notificationToPayee = require('./data/notificationToPayee'); describe('inboundModel', () => { let config; @@ -34,12 +34,7 @@ describe('inboundModel', () => { let logger; beforeAll(async () => { - const logTransports = await Promise.all([Transports.consoleDir()]); - logger = new Logger({ - context: { app: 'inbound-model-unit-tests' }, - space: 4, - transports: logTransports, - }); + logger = new Logger.Logger({ context: { app: 'inbound-model-unit-tests' }, stringify: () => '' }); }); beforeEach(async () => { @@ -117,6 +112,7 @@ describe('inboundModel', () => { let cache; beforeEach(async () => { + // eslint-disable-next-line no-unused-vars expectedQuoteResponseILP = Ilp.__response; BackendRequests.__postBulkQuotes = jest.fn().mockReturnValue(Promise.resolve(mockArgs.internalBulkQuoteResponse)); @@ -126,7 +122,7 @@ describe('inboundModel', () => { logger, }); await cache.connect(); - + // eslint-disable-next-line no-unused-vars model = new Model({ ...config, cache, @@ -139,7 +135,7 @@ describe('inboundModel', () => { await cache.disconnect(); }); - test('calls `mojaloopRequests.putBulkQuotes` with the expected arguments.', async () => { + test('calls mojaloopRequests.putBulkQuotes with the expected arguments.', async () => { await model.bulkQuoteRequest(mockArgs.bulkQuoteRequest, mockArgs.fspId); expect(MojaloopRequests.__putBulkQuotes).toHaveBeenCalledTimes(1); @@ -148,8 +144,7 @@ describe('inboundModel', () => { expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][1].individualQuoteResults[0].condition).toBe(expectedQuoteResponseILP.condition); expect(MojaloopRequests.__putBulkQuotes.mock.calls[0][2]).toBe(mockArgs.fspId); }); - - test('adds a custom `expiration` property in case it is not defined.', async() => { + test('adds a custom expiration property in case it is not defined.', async() => { // set a custom mock time in the global Date object in order to avoid race conditions. // Make sure to clear it at the end of the test case. const currentTime = new Date().getTime(); @@ -169,7 +164,6 @@ describe('inboundModel', () => { dateSpy.mockClear(); }); - }); describe('transactionRequest', () => { @@ -595,4 +589,39 @@ describe('inboundModel', () => { expect(MojaloopRequests.__putBulkTransfers).toHaveBeenCalledTimes(1); }); }); + + describe('sendNotificationToPayee:', () => { + const transferId = '1234'; + let cache; + + beforeEach(async () => { + cache = new Cache({ + host: 'dummycachehost', + port: 1234, + logger, + }); + await cache.connect(); + }); + + afterEach(async () => { + await cache.disconnect(); + }); + + test('sends notification to fsp backend', async () => { + BackendRequests.__putTransfersNotification = jest.fn().mockReturnValue(Promise.resolve({})); + const backendResponse = JSON.parse(JSON.stringify(notificationToPayee)); + + const model = new Model({ + ...config, + cache, + logger, + }); + + await model.sendNotificationToPayee(backendResponse.data, transferId); + expect(BackendRequests.__putTransfersNotification).toHaveBeenCalledTimes(1); + const call = BackendRequests.__putTransfersNotification.mock.calls[0]; + expect(call[0]).toEqual(backendResponse.data); + expect(call[1]).toEqual(transferId); + }); + }); }); diff --git a/src/test/unit/lib/model/OutboundBulkQuotesModel.test.js b/src/test/unit/lib/model/OutboundBulkQuotesModel.test.js index 654370c71..4387999da 100644 --- a/src/test/unit/lib/model/OutboundBulkQuotesModel.test.js +++ b/src/test/unit/lib/model/OutboundBulkQuotesModel.test.js @@ -14,12 +14,10 @@ jest.mock('@mojaloop/sdk-standard-components'); jest.mock('redis'); -const util = require('util'); const Cache = require('@internal/cache'); const Model = require('@internal/model').OutboundBulkQuotesModel; -const { Logger, Transports } = require('@internal/log'); -const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, Logger } = require('@mojaloop/sdk-standard-components'); const StateMachine = require('javascript-state-machine'); const defaultConfig = require('./data/defaultConfig'); @@ -62,6 +60,7 @@ describe('OutboundBulkQuotesModel', () => { ...config, cache, logger, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); @@ -81,8 +80,7 @@ describe('OutboundBulkQuotesModel', () => { } beforeAll(async () => { - const logTransports = await Promise.all([Transports.consoleDir()]); - logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests-cache' }, stringify: () => '' }); bulkQuoteResponse = JSON.parse(JSON.stringify(bulkQuoteResponseTemplate)); }); @@ -110,6 +108,7 @@ describe('OutboundBulkQuotesModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); @@ -121,11 +120,12 @@ describe('OutboundBulkQuotesModel', () => { emitBulkQuoteResponseCacheMessage(cache, bulkQuoteId, bulkQuoteResponse); return Promise.resolve(); }); - + const model = new Model({ cache, logger, ...config, + tls: config.outbound.tls, }); const BULK_QUOTE_ID = 'bq-id000011'; @@ -140,8 +140,6 @@ describe('OutboundBulkQuotesModel', () => { // start the model running const result = await model.run(); - console.log(`Result after get bulk quote: ${util.inspect(result)}`); - expect(MojaloopRequests.__getBulkQuotes).toHaveBeenCalledTimes(1); // check we stopped at succeeded state @@ -168,6 +166,7 @@ describe('OutboundBulkQuotesModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); @@ -177,8 +176,6 @@ describe('OutboundBulkQuotesModel', () => { // start the model running const result = await model.run(); - console.log(`Result after bulk quote: ${util.inspect(result)}`); - expect(MojaloopRequests.__postBulkQuotes).toHaveBeenCalledTimes(1); // check we stopped at 'succeeded' state @@ -231,6 +228,7 @@ describe('OutboundBulkQuotesModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkQuoteRequest))); diff --git a/src/test/unit/lib/model/OutboundBulkTransfersModel.test.js b/src/test/unit/lib/model/OutboundBulkTransfersModel.test.js index 4148d4936..8c6e00358 100644 --- a/src/test/unit/lib/model/OutboundBulkTransfersModel.test.js +++ b/src/test/unit/lib/model/OutboundBulkTransfersModel.test.js @@ -14,12 +14,10 @@ jest.mock('@mojaloop/sdk-standard-components'); jest.mock('redis'); -const util = require('util'); const Cache = require('@internal/cache'); const Model = require('@internal/model').OutboundBulkTransfersModel; -const { Logger, Transports } = require('@internal/log'); -const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, Logger } = require('@mojaloop/sdk-standard-components'); const StateMachine = require('javascript-state-machine'); const defaultConfig = require('./data/defaultConfig'); @@ -59,12 +57,13 @@ describe('outboundBulkTransferModel', () => { ...config, cache, logger, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); let expectError; - + if (rejects.transferFulfils && delays.prepareTransfer && expirySeconds < delays.prepareTransfer) { expectError = 'Bulk transfer fulfils missed expiry deadline'; } @@ -77,8 +76,7 @@ describe('outboundBulkTransferModel', () => { } beforeAll(async () => { - const logTransports = await Promise.all([Transports.consoleDir()]); - logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests-cache' }, stringify: () => '' }); }); beforeEach(async () => { @@ -102,6 +100,7 @@ describe('outboundBulkTransferModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); @@ -128,6 +127,7 @@ describe('outboundBulkTransferModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); @@ -137,8 +137,6 @@ describe('outboundBulkTransferModel', () => { // start the model running const result = await model.run(); - console.log(`Result after bulk transfer stage: ${util.inspect(result)}`); - expect(MojaloopRequests.__postBulkTransfers).toHaveBeenCalledTimes(1); // check we stopped at succeeded state @@ -156,6 +154,7 @@ describe('outboundBulkTransferModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); const BULK_TRANSFER_ID = 'btx-id000011'; @@ -171,8 +170,6 @@ describe('outboundBulkTransferModel', () => { // start the model running const result = await model.run(); - console.log(`Result after get bulk transfer: ${util.inspect(result)}`); - expect(MojaloopRequests.__getBulkTransfers).toHaveBeenCalledTimes(1); // check we stopped at succeeded state @@ -226,6 +223,7 @@ describe('outboundBulkTransferModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(bulkTransferRequest))); diff --git a/src/test/unit/lib/model/OutboundRequestToPayModel.test.js b/src/test/unit/lib/model/OutboundRequestToPayModel.test.js index 55a2f2b3f..8fb8a43de 100644 --- a/src/test/unit/lib/model/OutboundRequestToPayModel.test.js +++ b/src/test/unit/lib/model/OutboundRequestToPayModel.test.js @@ -14,12 +14,10 @@ jest.mock('@mojaloop/sdk-standard-components'); jest.mock('redis'); -const util = require('util'); const Cache = require('@internal/cache'); const Model = require('@internal/model').OutboundRequestToPayModel; -const { Logger, Transports } = require('@internal/log'); -const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, Logger } = require('@mojaloop/sdk-standard-components'); const StateMachine = require('javascript-state-machine'); const defaultConfig = require('./data/defaultConfig'); @@ -55,10 +53,9 @@ describe('outboundModel', () => { * @param {boolean} rejects.quoteResponse * @param {boolean} rejects.transferFulfils */ - + beforeAll(async () => { - const logTransports = await Promise.all([Transports.consoleDir()]); - logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests-cache' }, stringify: () => '' }); transactionRequestResponse = JSON.parse(JSON.stringify(transactionRequestResponseTemplate)); }); @@ -67,7 +64,7 @@ describe('outboundModel', () => { MojaloopRequests.__postParticipants = jest.fn(() => Promise.resolve()); MojaloopRequests.__getParties = jest.fn(() => Promise.resolve()); MojaloopRequests.__postTransactionRequests = jest.fn(() => Promise.resolve()); - + cache = new Cache({ host: 'dummycachehost', port: 1234, @@ -85,6 +82,7 @@ describe('outboundModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(requestToPayRequest))); @@ -94,7 +92,7 @@ describe('outboundModel', () => { test('executes all two stages without halting when AUTO_ACCEPT_PARTY is true', async () => { config.autoAcceptParty = true; - + MojaloopRequests.__getParties = jest.fn(() => { emitPartyCacheMessage(cache, payeeParty); return Promise.resolve(); @@ -110,6 +108,7 @@ describe('outboundModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(requestToPayRequest))); @@ -119,11 +118,9 @@ describe('outboundModel', () => { // start the model running const result = await model.run(); - console.log(`Result after two stage transfer: ${util.inspect(result)}`); - expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1); expect(MojaloopRequests.__postTransactionRequests).toHaveBeenCalledTimes(1); - + // check we stopped at payeeResolved state expect(result.currentState).toBe('COMPLETED'); expect(result.requestToPayState).toBe('RECEIVED'); @@ -137,11 +134,12 @@ describe('outboundModel', () => { emitPartyCacheMessage(cache, payeeParty); return Promise.resolve(); }); - + const model = new Model({ cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(requestToPayRequest))); @@ -157,12 +155,10 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state const result = await resultPromise; - console.log(`Result after resolve payee: ${util.inspect(result)}`); - // check we stopped at payeeResolved state expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE'); expect(StateMachine.__instance.state).toBe('payeeResolved'); }); - - + + }); diff --git a/src/test/unit/lib/model/OutboundRequestToPayTransferModel.test.js b/src/test/unit/lib/model/OutboundRequestToPayTransferModel.test.js index 78f771233..81384a2dd 100644 --- a/src/test/unit/lib/model/OutboundRequestToPayTransferModel.test.js +++ b/src/test/unit/lib/model/OutboundRequestToPayTransferModel.test.js @@ -14,12 +14,10 @@ jest.mock('@mojaloop/sdk-standard-components'); jest.mock('redis'); -const util = require('util'); const Cache = require('@internal/cache'); const Model = require('@internal/model').OutboundRequestToPayTransferModel; -const { Logger, Transports } = require('@internal/log'); -const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, Logger } = require('@mojaloop/sdk-standard-components'); const StateMachine = require('javascript-state-machine'); const defaultConfig = require('./data/defaultConfig'); @@ -55,11 +53,10 @@ describe('outboundRequestToPayTransferModel', () => { * @param {boolean} rejects.quoteResponse * @param {boolean} rejects.transferFulfils */ - + beforeAll(async () => { - const logTransports = await Promise.all([Transports.consoleDir()]); - logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests-cache' }, stringify: () => '' }); quoteResponse = JSON.parse(JSON.stringify(quoteResponseTemplate)); }); @@ -90,6 +87,7 @@ describe('outboundRequestToPayTransferModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(requestToPayTransferRequest))); @@ -144,6 +142,7 @@ describe('outboundRequestToPayTransferModel', () => { cache, logger, ...config, + tls: config.outbound.tls, }); await model.initialize(JSON.parse(JSON.stringify(requestToPayTransferRequest))); @@ -153,8 +152,6 @@ describe('outboundRequestToPayTransferModel', () => { // start the model running const result = await model.run(); - console.log(`Result after three stage transfer: ${util.inspect(result)}`); - expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1); expect(MojaloopRequests.__getAuthorizations).toHaveBeenCalledTimes(1); expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1); @@ -165,7 +162,7 @@ describe('outboundRequestToPayTransferModel', () => { }); // test('halts and resumes after quotes and otp stages when AUTO_ACCEPT_QUOTES is false and AUTO_ACCEPT_OTP is false', async () => { - + // config.autoAcceptR2PDeviceOTP = false; // config.autoAcceptR2PDeviceQuotes = false; @@ -173,6 +170,7 @@ describe('outboundRequestToPayTransferModel', () => { // cache, // logger, // ...config, + // tls: config.outbound.tls, // }); // await model.initialize(JSON.parse(JSON.stringify(requestToPayTransferRequest))); @@ -188,8 +186,6 @@ describe('outboundRequestToPayTransferModel', () => { // // wait for the model to reach a terminal state // let result = await resultPromise; - // console.log(`Result after request quote: ${util.inspect(result)}`); - // // check we stopped at quoteReceived state // expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE'); // expect(StateMachine.__instance.state).toBe('quoteReceived'); @@ -201,6 +197,7 @@ describe('outboundRequestToPayTransferModel', () => { // cache, // logger, // ...config, + // tls: config.outbound.tls, // }); // await model.load(requestToPayTransactionId); @@ -217,8 +214,6 @@ describe('outboundRequestToPayTransferModel', () => { // // wait for the model to reach a terminal state // result = await resultPromise; - // console.log(`Result after request otp: ${util.inspect(result)}`); - // // check we stopped at quoteReceived state // expect(result.currentState).toBe('WAITING_FOR_OTP_ACCEPTANCE'); // expect(StateMachine.__instance.state).toBe('otpReceived'); @@ -228,6 +223,7 @@ describe('outboundRequestToPayTransferModel', () => { // cache, // logger, // ...config, + // tls: config.outbound.tls, // }); // await model.load(requestToPayTransactionId); @@ -244,13 +240,11 @@ describe('outboundRequestToPayTransferModel', () => { // // wait for the model to reach a terminal state // result = await resultPromise; - // console.log(`Result after transfer fulfil: ${util.inspect(result)}`); - // // check we stopped at quoteReceived state // expect(result.currentState).toBe('COMPLETED'); // expect(StateMachine.__instance.state).toBe('succeeded'); // }); - + }); diff --git a/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js index 0a005abcc..e765f2b7a 100644 --- a/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js +++ b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js @@ -322,6 +322,21 @@ describe('thirdpartyTransactionModel', () => { it('should unsubscribe from cache in case when error happens in workflow run', async () => { data.transactionRequestId = uuid(); + data.currentState = 'postTransaction'; + let payerInformation = { + 'personalInfo': { + 'complexName': { + 'firstName': 'Ayesha', + 'lastName': 'Takia' + } + }, + 'partyIdInfo': { + 'partyIdType': 'MSISDN', + 'partyIdentifier': '+44 8765 4321', + 'fspId': 'dfspa' + } + }; + data.payer = payerInformation; const channel = Model.notificationChannel(data.transactionRequestId); const model = await Model.create(data, cacheKey, modelConfig); const { cache } = model.context; diff --git a/src/test/unit/lib/model/OutboundTransfersModel.test.js b/src/test/unit/lib/model/OutboundTransfersModel.test.js index 6a1f6192f..3d197bdd3 100644 --- a/src/test/unit/lib/model/OutboundTransfersModel.test.js +++ b/src/test/unit/lib/model/OutboundTransfersModel.test.js @@ -14,12 +14,10 @@ jest.mock('@mojaloop/sdk-standard-components'); jest.mock('redis'); -const util = require('util'); const Cache = require('@internal/cache'); const Model = require('@internal/model').OutboundTransfersModel; -const { Logger, Transports } = require('@internal/log'); -const { MojaloopRequests } = require('@mojaloop/sdk-standard-components'); +const { MojaloopRequests, Logger } = require('@mojaloop/sdk-standard-components'); const StateMachine = require('javascript-state-machine'); const defaultConfig = require('./data/defaultConfig'); @@ -108,8 +106,7 @@ describe('outboundModel', () => { } beforeAll(async () => { - const logTransports = await Promise.all([Transports.consoleDir()]); - logger = new Logger({ context: { app: 'outbound-model-unit-tests-cache' }, space: 4, transports: logTransports }); + logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests-cache' }, stringify: () => '' }); quoteResponse = JSON.parse(JSON.stringify(quoteResponseTemplate)); }); @@ -202,8 +199,6 @@ describe('outboundModel', () => { // start the model running const result = await model.run(); - console.log(`Result after three stage transfer: ${util.inspect(result)}`); - expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1); expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1); expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1); @@ -284,8 +279,6 @@ describe('outboundModel', () => { // start the model running const result = await model.run(); - console.log(`Result after three stage transfer: ${util.inspect(result)}`); - expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1); expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1); expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1); @@ -321,8 +314,6 @@ describe('outboundModel', () => { // start the model running const result = await model.run(); - console.log(`Result after get transfer: ${util.inspect(result)}`); - expect(MojaloopRequests.__getTransfers).toHaveBeenCalledTimes(1); // check we stopped at payeeResolved state @@ -353,8 +344,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state const result = await resultPromise; - console.log(`Result after resolve payee: ${util.inspect(result)}`); - // check we stopped at payeeResolved state expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE'); expect(StateMachine.__instance.state).toBe('payeeResolved'); @@ -384,8 +373,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state let result = await resultPromise; - console.log(`Result after resolve payee: ${util.inspect(result)}`); - // check we stopped at payeeResolved state expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE'); expect(StateMachine.__instance.state).toBe('payeeResolved'); @@ -413,8 +400,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state result = await resultPromise; - console.log(`Result after request quote: ${util.inspect(result)}`); - // check we stopped at payeeResolved state expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE'); expect(StateMachine.__instance.state).toBe('quoteReceived'); @@ -444,8 +429,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state let result = await resultPromise; - console.log(`Result after resolve payee: ${util.inspect(result)}`); - // check we stopped at payeeResolved state expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE'); expect(StateMachine.__instance.state).toBe('payeeResolved'); @@ -473,8 +456,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state result = await resultPromise; - console.log(`Result after request quote: ${util.inspect(result)}`); - // check we stopped at quoteReceived state expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE'); expect(StateMachine.__instance.state).toBe('quoteReceived'); @@ -500,8 +481,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state result = await resultPromise; - console.log(`Result after transfer fulfil: ${util.inspect(result)}`); - // check we stopped at quoteReceived state expect(result.currentState).toBe('COMPLETED'); expect(StateMachine.__instance.state).toBe('succeeded'); @@ -553,8 +532,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state const result = await resultPromise; - console.log(`Result after three stage transfer: ${util.inspect(result)}`); - // check we stopped at payeeResolved state expect(result.currentState).toBe('COMPLETED'); expect(StateMachine.__instance.state).toBe('succeeded'); @@ -606,8 +583,6 @@ describe('outboundModel', () => { // wait for the model to reach a terminal state const result = await resultPromise; - console.log(`Result after three stage transfer: ${util.inspect(result)}`); - // check we stopped at payeeResolved state expect(result.currentState).toBe('COMPLETED'); expect(StateMachine.__instance.state).toBe('succeeded'); @@ -836,7 +811,7 @@ describe('outboundModel', () => { async function testTlsServer(enableTls) { - config.tls.outbound.mutualTLS.enabled = enableTls; + config.outbound.tls.mutualTLS.enabled = enableTls; new Model({ cache, diff --git a/src/test/unit/lib/model/PartiesModel.test.js b/src/test/unit/lib/model/PartiesModel.test.js index 787591d30..fe98c73ca 100644 --- a/src/test/unit/lib/model/PartiesModel.test.js +++ b/src/test/unit/lib/model/PartiesModel.test.js @@ -23,7 +23,7 @@ describe('PartiesModel', () => { let cacheKey; let data; let modelConfig; - + const subId = 123; let handler = null; beforeEach(async () => { @@ -41,7 +41,7 @@ describe('PartiesModel', () => { handler = jest.fn(h); return subId; }), - + // mock publish and call stored handler publish: jest.fn(async (channel, message) => await handler(channel, message, subId)), @@ -50,7 +50,7 @@ describe('PartiesModel', () => { ...defaultConfig }; data = { the: 'mocked data' }; - + cacheKey = 'cache-key'; }); @@ -59,7 +59,7 @@ describe('PartiesModel', () => { const model = await Model.create(data, cacheKey, modelConfig); expect(model.state).toBe('start'); - + // model's methods layout const methods = [ 'run', 'getResponse', @@ -71,7 +71,7 @@ describe('PartiesModel', () => { }); describe('getResponse', () => { - + it('should remap currentState', async () => { const model = await Model.create(data, cacheKey, modelConfig); const states = model.allStates(); @@ -81,12 +81,12 @@ describe('PartiesModel', () => { const result = model.getResponse(); expect(result.currentState).toEqual(Model.mapCurrentState[state]); }); - + }); it('should handle unexpected state', async() => { const model = await Model.create(data, cacheKey, modelConfig); - + // simulate lack of state by undefined property delete model.context.data.currentState; @@ -144,9 +144,9 @@ describe('PartiesModel', () => { // check that this.context.data is updated expect(model.context.data).toEqual({ ...message, - // current state will be updated by onAfterTransition which isn't called + // current state will be updated by onAfterTransition which isn't called // when manual invocation of transition handler happens - currentState: 'start' + currentState: 'start' }); }); @@ -155,7 +155,7 @@ describe('PartiesModel', () => { // ensure that cache.unsubscribe does not happened before fire the message expect(cache.unsubscribe).not.toBeCalled(); - + // fire publication with given message await cache.publish(channel, JSON.stringify(message)); @@ -181,10 +181,10 @@ describe('PartiesModel', () => { model.onRequestPartiesInformation(model.fsm, type, id, subIdValue).catch((err) => { expect(err.message).toEqual('Unexpected token u in JSON at position 0'); expect(cache.unsubscribe).toBeCalledTimes(1); - expect(cache.unsubscribe).toBeCalledWith(channel, subId); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); }); - // fire publication to channel with invalid message + // fire publication to channel with invalid message // should throw the exception from JSON.parse await cache.publish(channel, undefined); @@ -224,7 +224,7 @@ describe('PartiesModel', () => { const subIdValue = uuid(); const model = await Model.create(data, cacheKey, modelConfig); - + model.requestPartiesInformation = jest.fn(); model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); @@ -246,12 +246,12 @@ describe('PartiesModel', () => { const subIdValue = uuid(); const model = await Model.create(data, cacheKey, modelConfig); - + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); - + model.context.data.currentState = 'succeeded'; const result = await model.run(type, id, subIdValue); - + expect(result).toEqual({the: 'response'}); expect(model.getResponse).toBeCalledTimes(1); expect(model.context.logger.log).toBeCalledWith('Party information retrieved successfully'); @@ -263,12 +263,12 @@ describe('PartiesModel', () => { const subIdValue = uuid(); const model = await Model.create(data, cacheKey, modelConfig); - + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); - + model.context.data.currentState = 'errored'; const result = await model.run(type, id, subIdValue); - + expect(result).toBeFalsy(); expect(model.getResponse).not.toBeCalled(); expect(model.context.logger.log).toBeCalledWith('State machine in errored state'); @@ -280,7 +280,7 @@ describe('PartiesModel', () => { const subIdValue = uuid(); const model = await Model.create(data, cacheKey, modelConfig); - + model.requestPartiesInformation = jest.fn(() => { const err = new Error('requestPartiesInformation failed'); err.requestPartiesInformationState = 'some'; @@ -288,7 +288,7 @@ describe('PartiesModel', () => { }); model.error = jest.fn(); model.context.data.currentState = 'start'; - + let theError = null; try { await model.run(type, id, subIdValue); @@ -303,4 +303,4 @@ describe('PartiesModel', () => { expect(model.error).toBeCalledTimes(1); }); }); -}); \ No newline at end of file +}); diff --git a/src/test/unit/lib/model/common/PersistentStateMachine.test.js b/src/test/unit/lib/model/common/PersistentStateMachine.test.js index 1a23ae22c..d90086ce7 100644 --- a/src/test/unit/lib/model/common/PersistentStateMachine.test.js +++ b/src/test/unit/lib/model/common/PersistentStateMachine.test.js @@ -19,12 +19,12 @@ describe('PersistentStateMachine', () => { let data; let smSpec; const key = 'cache-key'; - + const logger = mockLogger({app: 'persistent-state-machine-test'}); function checkPSMLayout(psm, optData) { expect(psm).toBeTruthy(); - + expect(psm.state).toEqual((optData && optData.currentState) || smSpec.init || 'none'); expect(psm.context).toEqual({ @@ -54,7 +54,7 @@ describe('PersistentStateMachine', () => { { name: 'init', from: 'none', to: 'start'}, { name: 'gogo', from: 'start', to: 'end' }, { name: 'error', from: '*', to: 'errored' } - ], + ], methods: { onGogo: async () => { return new Promise( (resolved) => { @@ -62,14 +62,14 @@ describe('PersistentStateMachine', () => { } ); }, onError: () => { - console.error('onError'); + logger.log('onError'); } } }; // test data data = { the: 'data' }; - + cache = new Cache({ host: 'dummycachehost', port: 1234, @@ -99,20 +99,20 @@ describe('PersistentStateMachine', () => { expect(psm.state).toEqual('start'); }); - describe('onPendingTransition', () => { + describe('onPendingTransition', () => { it('should throw error if not `error` transition', async () => { const psm = await PSM.create(data, cache, key, logger, smSpec); checkPSMLayout(psm); psm.init(); expect(() => psm.gogo()).toThrowError('Transition \'gogo\' requested while another transition is in progress'); - + }); it('should not throw error if `error` transition called when `gogo` is pending', (done) => { PSM.create(data, cache, key, logger, smSpec).then((psm) => { checkPSMLayout(psm); - + psm.init() .then(() => { expect(psm.state).toEqual('start'); @@ -124,7 +124,7 @@ describe('PersistentStateMachine', () => { .then(done) .catch(shouldNotBeExecuted); }); - }); + }); }); describe('loadFromCache', () => { @@ -162,16 +162,16 @@ describe('PersistentStateMachine', () => { describe('saveToCache', () => { it('should rethrow error from cache.set', async () => { - + // mock to simulate throwing error cache.set = jest.fn(() => { throw new Error('error from cache.set'); }); - + const psm = await PSM.create(data, cache, key, logger, smSpec); checkPSMLayout(psm); - // transition `init` should encounter exception when saving `context.data` + // transition `init` should encounter exception when saving `context.data` expect(() => psm.init()).rejects.toEqual(new Error('error from cache.set')); }); }); -}); \ No newline at end of file +}); diff --git a/src/test/unit/lib/model/data/defaultConfig.json b/src/test/unit/lib/model/data/defaultConfig.json index ec54225e2..aae68ea22 100644 --- a/src/test/unit/lib/model/data/defaultConfig.json +++ b/src/test/unit/lib/model/data/defaultConfig.json @@ -1,6 +1,26 @@ { - "inboundPort": 4000, - "outboundPort": 4001, + "inbound": { + "port": 4000, + "tls": { + "mutualTLS": { "enabled": false }, + "creds": { + "ca": null, + "cert": null, + "key": null + } + } + }, + "outbound": { + "port": 4001, + "tls": { + "mutualTLS": { "enabled": false }, + "creds": { + "ca": null, + "cert": null, + "key": null + } + } + }, "peerEndpoint": "172.17.0.2:3001", "backendEndpoint": "172.17.0.2:3001", "alsEndpoint": "127.0.0.1:6500", @@ -12,24 +32,6 @@ "autoAcceptQuotes": true, "autoAcceptParty": true, "useQuoteSourceFSPAsTransferPayeeFSP": false, - "tls": { - "inbound": { - "mutualTLS": { "enabled": false }, - "creds": { - "ca": null, - "cert": null, - "key": null - } - }, - "outbound": { - "mutualTLS": { "enabled": false }, - "creds": { - "ca": null, - "cert": null, - "key": null - } - } - }, "validateInboundJws": true, "validateInboundPutPartiesJws": false, "jwsSign": true, @@ -45,8 +47,10 @@ "enabled": false, "listenPort": 6000 }, - "wso2Auth": { - "refreshSeconds": 3600 + "wso2": { + "auth": { + "refreshSeconds": 3600 + } }, "rejectExpiredQuoteResponses": false, "rejectExpiredTransferFulfils": false, diff --git a/src/test/unit/lib/model/data/mockArguments.json b/src/test/unit/lib/model/data/mockArguments.json index e1335f0da..b010d9d07 100644 --- a/src/test/unit/lib/model/data/mockArguments.json +++ b/src/test/unit/lib/model/data/mockArguments.json @@ -116,7 +116,7 @@ }, "internalBulkQuoteResponse": { "expiration": "2019-11-12T09:02:10.378Z", - "individualQuotes": [ + "individualQuoteResults": [ { "quoteId": "fake-quote-id", "transferAmount": 500, diff --git a/src/test/unit/lib/model/data/notificationToPayee.json b/src/test/unit/lib/model/data/notificationToPayee.json new file mode 100644 index 000000000..49c2a1ab2 --- /dev/null +++ b/src/test/unit/lib/model/data/notificationToPayee.json @@ -0,0 +1,10 @@ +{ + "type": "notificationToPayee", + "headers": { + "fspiop-source": "SWITCH" + }, + "data": { + "completedTimestamp": "2017-11-15T14:16:09.663+01:00", + "transferState": "COMMITTED" + } +} diff --git a/src/test/unit/mockLogger.js b/src/test/unit/mockLogger.js index f77baea07..32ba36363 100644 --- a/src/test/unit/mockLogger.js +++ b/src/test/unit/mockLogger.js @@ -8,7 +8,7 @@ * Paweł Marzec - pawel.marzec@modusbox.com * **************************************************************************/ -const { Logger, Transports } = require('@internal/log'); +const { Logger } = require('@mojaloop/sdk-standard-components'); function mockLogger(context, keepQuiet) { // if keepQuite is undefined then be quiet @@ -24,8 +24,7 @@ function mockLogger(context, keepQuiet) { }; } // let be elaborative and dir logging to console - const consoleTransport = Transports.consoleDir(); - return new Logger({ context: context, space: 4, transports: [ consoleTransport ] }); + return new Logger({ context: context, space: 4 }); } -module.exports = mockLogger; \ No newline at end of file +module.exports = mockLogger; diff --git a/src/test/unit/outboundApi/handlers.test.js b/src/test/unit/outboundApi/handlers.test.js index f748224fb..8e3bed363 100644 --- a/src/test/unit/outboundApi/handlers.test.js +++ b/src/test/unit/outboundApi/handlers.test.js @@ -406,7 +406,6 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - wso2Auth: 'mocked wso2Auth', logger: mockLogger({ app: 'outbound-api-handlers-test'}), cache: { the: 'mocked cache' } }, @@ -453,7 +452,6 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - wso2Auth: 'mocked wso2Auth', logger: mockLogger({ app: 'outbound-api-handlers-test'}), cache: { the: 'mocked cache' }, path: { @@ -501,7 +499,6 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - wso2Auth: 'mocked wso2Auth', logger: mockLogger({ app: 'outbound-api-handlers-test'}), cache: { the: 'mocked cache' }, path: { @@ -551,7 +548,6 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - wso2Auth: 'mocked wso2Auth', logger: mockLogger({ app: 'outbound-api-handlers-test'}), cache: { the: 'mocked cache' }, path: { @@ -598,7 +594,6 @@ describe('Outbound API handlers:', () => { response: {}, state: { conf: {}, - wso2Auth: 'mocked wso2Auth', logger: mockLogger({ app: 'outbound-api-handlers-test'}), cache: { the: 'mocked cache' }, path: { From 28704e22f28eeaad0e2a89e7331067186e031a34 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Mon, 4 Jan 2021 07:38:07 -0500 Subject: [PATCH 27/29] chore: update config in models (#238) --- src/lib/model/AccountsModel.js | 2 +- src/lib/model/OutboundBulkTransfersModel.js | 4 +-- src/lib/model/OutboundRequestToPayModel.js | 4 +-- .../OutboundRequestToPayTransferModel.js | 34 +++++++++---------- src/lib/model/ProxyModel/index.js | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/lib/model/AccountsModel.js b/src/lib/model/AccountsModel.js index 33c55bfd2..36a6279a3 100644 --- a/src/lib/model/AccountsModel.js +++ b/src/lib/model/AccountsModel.js @@ -36,7 +36,7 @@ class AccountsModel { logger: this._logger, peerEndpoint: config.alsEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSigningKey: config.jwsSigningKey, wso2: config.wso2, diff --git a/src/lib/model/OutboundBulkTransfersModel.js b/src/lib/model/OutboundBulkTransfersModel.js index 526dc0453..fb0bbed28 100644 --- a/src/lib/model/OutboundBulkTransfersModel.js +++ b/src/lib/model/OutboundBulkTransfersModel.js @@ -38,7 +38,7 @@ class OutboundBulkTransfersModel { peerEndpoint: config.peerEndpoint, bulkTransfersEndpoint: config.bulkTransfersEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, @@ -256,7 +256,7 @@ class OutboundBulkTransfersModel { ilpPacket: individualTransfer.ilpPacket, condition: individualTransfer.condition, }; - + if (individualTransfer.extensions && individualTransfer.extensions.length > 0) { bulkTransferRequest.extensionList = { extension: individualTransfer.extensions diff --git a/src/lib/model/OutboundRequestToPayModel.js b/src/lib/model/OutboundRequestToPayModel.js index 6f88e4b38..c22b04597 100644 --- a/src/lib/model/OutboundRequestToPayModel.js +++ b/src/lib/model/OutboundRequestToPayModel.js @@ -38,7 +38,7 @@ class OutboundRequestToPayModel { peerEndpoint: config.peerEndpoint, alsEndpoint: config.alsEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, @@ -293,7 +293,7 @@ class OutboundRequestToPayModel { this._logger.push({ transactionRequestResponse }).log('Transaction Request Response received'); this.data.requestToPayState = transactionRequestResponse.transactionRequestState; - + return resolve(transactionRequestResponse); } catch(err) { diff --git a/src/lib/model/OutboundRequestToPayTransferModel.js b/src/lib/model/OutboundRequestToPayTransferModel.js index e61cd21b6..a6ffd863f 100644 --- a/src/lib/model/OutboundRequestToPayTransferModel.js +++ b/src/lib/model/OutboundRequestToPayTransferModel.js @@ -54,7 +54,7 @@ class OutboundRequestToPayTransferModel { authorizationsEndpoint: config.authorizationsEndpoint, transfersEndpoint: config.transfersEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSignPutParties: config.jwsSignPutParties, jwsSigningKey: config.jwsSigningKey, @@ -138,7 +138,7 @@ class OutboundRequestToPayTransferModel { await this.stateMachine.requestQuote(); this._logger.log(`Quote received for transfer ${this.data.transferId}`); - + if(this.stateMachine.state === 'quoteReceived' && this.data.initiatorType === 'BUSINESS' && !this._autoAcceptR2PBusinessQuotes) { // kick-off postAuthorizations here for PISP flow //we break execution here and return the quote response details to allow asynchronous accept or reject @@ -167,8 +167,8 @@ class OutboundRequestToPayTransferModel { } } break; - - case 'authorizationReceived': + + case 'authorizationReceived': // next transition is executeTransfer await this.stateMachine.executeAuthorizedTransfer(); this._logger.log(`Transfer ${this.data.transferId} has been completed`); @@ -263,7 +263,7 @@ class OutboundRequestToPayTransferModel { } /** - * This method is used to communicate back to the Payee that a rejection is being + * This method is used to communicate back to the Payee that a rejection is being * sent because the OTP did not match. */ async rejectRequestToPay() { @@ -274,7 +274,7 @@ class OutboundRequestToPayTransferModel { const response = { status : `${this.data.requestToPayTransactionId} has been REJECTED` }; - return JSON.stringify(response); + return JSON.stringify(response); } @@ -520,7 +520,7 @@ class OutboundRequestToPayTransferModel { transactionRequestId: this.data.requestToPayTransactionId, quote: { ...this.data.quoteResponse }, }; - + const modelConfig = { ...this._config }; const cacheKey = `post_authorizations_${authorizationsRequest.transactionRequestId}`; @@ -531,11 +531,11 @@ class OutboundRequestToPayTransferModel { // run model's workflow this.data.authorizationResponse = await model.run(); - // here is POC: happy flow + // here is POC: happy flow // the authorizationResponse should be analyzed // and the pinValue should be validated but this is out of the scope of this POC this._logger.push({ authorizationResponse: this.data.authorizationResponse }).log('authorizationResponse received'); - + // let call backend service which will validate pinValue // TODO: add `/validate-authorization` path to mojaloop_simulator // const validateResponse = await this._backendRequests.validateAuthorization(this.data.authorizationResponse); @@ -547,7 +547,7 @@ class OutboundRequestToPayTransferModel { } /** - * Sends request for + * Sends request for * Starts the quote resolution process by sending a POST /quotes request to the switch; * then waits for a notification from the cache that the quote response has been received */ @@ -556,7 +556,7 @@ class OutboundRequestToPayTransferModel { return new Promise(async (resolve, reject) => { if( this.data.initiatorType && this.data.initiatorType === 'BUSINESS') return resolve(); - + // listen for events on the quoteId const otpKey = `otp_${this.data.requestToPayTransactionId}`; @@ -577,9 +577,9 @@ class OutboundRequestToPayTransferModel { const otpResponseBody = otpResponse.data; this._logger.push({ otpResponseBody }).log('OTP response received'); - + this.data.otpResponse = otpResponseBody; - + return resolve(otpResponse); } catch(err) { @@ -663,7 +663,7 @@ class OutboundRequestToPayTransferModel { return quote; } - + /** * Executes a transfer * Starts the transfer process by sending a POST /transfers (prepare) request to the switch; @@ -902,11 +902,11 @@ class OutboundRequestToPayTransferModel { case 'quoteReceived': resp.currentState = requestToPayTransferStateEnum.WAITING_FOR_QUOTE_ACCEPTANCE; break; - + case 'authorizationReceived': resp.currentState = requestToPayTransferStateEnum.WAITING_FOR_AUTHORIZATION_ACCEPTANCE; break; - + case 'otpReceived': resp.currentState = requestToPayTransferStateEnum.WAITING_FOR_OTP_ACCEPTANCE; break; @@ -966,7 +966,7 @@ class OutboundRequestToPayTransferModel { } - + } diff --git a/src/lib/model/ProxyModel/index.js b/src/lib/model/ProxyModel/index.js index 35549735b..0a5f489ea 100644 --- a/src/lib/model/ProxyModel/index.js +++ b/src/lib/model/ProxyModel/index.js @@ -36,7 +36,7 @@ class ProxyModel { logger: this._logger, peerEndpoint: config.peerEndpoint, dfspId: config.dfspId, - tls: config.tls, + tls: config.outbound.tls, jwsSign: config.jwsSign, jwsSigningKey: config.jwsSigningKey, wso2Auth: config.wso2Auth From 74b0aff50e0b75611ff22e91f2d686de9e320015 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Tue, 5 Jan 2021 06:53:58 -0500 Subject: [PATCH 28/29] fix: edit some faulty tests (#239) * fix: edit some faulty tests * chore: fix lint --- src/lib/model/OutboundAuthorizationsModel.js | 2 +- .../model/OutboundAuthorizationsModel.test.js | 79 +++++++++---------- ...OutboundThirdpartyTransactionModel.test.js | 41 +++++----- .../common/PersistentStateMachine.test.js | 3 +- 4 files changed, 60 insertions(+), 65 deletions(-) diff --git a/src/lib/model/OutboundAuthorizationsModel.js b/src/lib/model/OutboundAuthorizationsModel.js index fd0313d41..762761a87 100644 --- a/src/lib/model/OutboundAuthorizationsModel.js +++ b/src/lib/model/OutboundAuthorizationsModel.js @@ -138,7 +138,7 @@ async function onRequestAuthorization() { try { const parsed = JSON.parse(message); this.context.data = { - ...parsed, + ...parsed.data, currentState: this.state }; resolve(); diff --git a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js index 01e89bad9..008d6670e 100644 --- a/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js +++ b/src/test/unit/lib/model/OutboundAuthorizationsModel.test.js @@ -46,7 +46,7 @@ describe('authorizationsModel', () => { handler = jest.fn(h); return subId; }), - + // mock publish and call stored handler publish: jest.fn(async (channel, message) => await handler(channel, message, subId)), @@ -62,7 +62,7 @@ describe('authorizationsModel', () => { const model = await Model.create(data, cacheKey, modelConfig); expect(model.state).toBe('start'); - + // model's methods layout const methods = [ 'run', 'getResponse', @@ -85,7 +85,7 @@ describe('authorizationsModel', () => { }); describe('getResponse', () => { - + it('should remap currentState', async () => { const model = await Model.create(data, cacheKey, modelConfig); const states = model.allStates(); @@ -96,12 +96,12 @@ describe('authorizationsModel', () => { const result = model.getResponse(); expect(result.currentState).toEqual(Model.mapCurrentState[state]); }); - + }); it('should handle unexpected state', async() => { const model = await Model.create(data, cacheKey, modelConfig); - + // simulate lack of state by undefined property delete model.context.data.currentState; @@ -151,35 +151,13 @@ describe('authorizationsModel', () => { }; // manually invoke transition handler - model.onRequestAuthorization() - .then(() => { - // subscribe should be called only once - expect(cache.subscribe).toBeCalledTimes(1); - - // subscribe should be done to proper notificationChannel - expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); - - // check invocation of request.postAuthorizations - expect(MojaloopRequests.__postAuthorizations).toBeCalledWith(Model.buildPostAuthorizationsRequest(data, modelConfig), data.toParticipantId); - - - // check that this.context.data is updated - expect(model.context.data).toEqual({ - Iam: 'the-body', - transactionRequestId: model.context.data.transactionRequestId, - - // current state will be updated by onAfterTransition which isn't called - // when manual invocation of transition handler happens - currentState: 'start' - }); - }); + model.onRequestAuthorization(); // ensure handler wasn't called before publishing the message expect(handler).not.toBeCalled(); // ensure that cache.unsubscribe does not happened before fire the message expect(cache.unsubscribe).not.toBeCalled(); - // fire publication with given message await cache.publish(channel, JSON.stringify(message)); @@ -187,6 +165,25 @@ describe('authorizationsModel', () => { // handler should be called only once expect(handler).toBeCalledTimes(1); + // subscribe should be called only once + expect(cache.subscribe).toBeCalledTimes(1); + + // subscribe should be done to proper notificationChannel + expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); + + // check invocation of request.postAuthorizations + expect(MojaloopRequests.__postAuthorizations).toBeCalledWith(Model.buildPostAuthorizationsRequest(data, modelConfig), data.toParticipantId); + + // check that this.context.data is updated + expect(model.context.data).toEqual({ + Iam: 'the-body', + transactionRequestId: model.context.data.transactionRequestId, + + // current state will be updated by onAfterTransition which isn't called + // when manual invocation of transition handler happens + currentState: 'start' + }); + // handler should unsubscribe from notification channel expect(cache.unsubscribe).toBeCalledTimes(1); expect(cache.unsubscribe).toBeCalledWith(channel, subId); @@ -202,10 +199,10 @@ describe('authorizationsModel', () => { model.onRequestAuthorization().catch((err) => { expect(err.message).toEqual('Unexpected token u in JSON at position 0'); expect(cache.unsubscribe).toBeCalledTimes(1); - expect(cache.unsubscribe).toBeCalledWith(channel, subId); + expect(cache.unsubscribe).toBeCalledWith(channel, subId); }); - // fire publication to channel with invalid message + // fire publication to channel with invalid message // should throw the exception from JSON.parse await cache.publish(channel, undefined); @@ -215,7 +212,7 @@ describe('authorizationsModel', () => { // simulate error MojaloopRequests.__postAuthorizations = jest.fn(() => Promise.reject('postAuthorization failed')); data.transactionRequestId = uuid(); - + const channel = Model.notificationChannel(data.transactionRequestId); const model = await Model.create(data, cacheKey, modelConfig); const { cache } = model.context; @@ -239,7 +236,7 @@ describe('authorizationsModel', () => { describe('run workflow', () => { it('start', async () => { const model = await Model.create(data, cacheKey, modelConfig); - + model.requestAuthorization = jest.fn(); model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); @@ -256,12 +253,12 @@ describe('authorizationsModel', () => { }); it('succeeded', async () => { const model = await Model.create(data, cacheKey, modelConfig); - + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); - + model.context.data.currentState = 'succeeded'; const result = await model.run({the: 'message'}); - + expect(result).toEqual({the: 'response'}); expect(model.getResponse).toBeCalledTimes(1); expect(model.context.logger.log).toBeCalledWith('Authorization completed successfully'); @@ -269,12 +266,12 @@ describe('authorizationsModel', () => { it('errored', async () => { const model = await Model.create(data, cacheKey, modelConfig); - + model.getResponse = jest.fn(() => Promise.resolve({the: 'response'})); - + model.context.data.currentState = 'errored'; const result = await model.run({the: 'message'}); - + expect(result).toBeFalsy(); expect(model.getResponse).not.toBeCalled(); expect(model.context.logger.log).toBeCalledWith('State machine in errored state'); @@ -282,7 +279,7 @@ describe('authorizationsModel', () => { it('should handle errors', async () => { const model = await Model.create(data, cacheKey, modelConfig); - + model.requestAuthorization = jest.fn(() => { const err = new Error('requestAuthorization failed'); err.authorizationState = 'some'; @@ -290,7 +287,7 @@ describe('authorizationsModel', () => { }); model.error = jest.fn(); model.context.data.currentState = 'start'; - + let theError = null; try { await model.run(); @@ -305,4 +302,4 @@ describe('authorizationsModel', () => { expect(model.error).toBeCalledTimes(1); }); }); -}); \ No newline at end of file +}); diff --git a/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js index e765f2b7a..aecf9eabd 100644 --- a/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js +++ b/src/test/unit/lib/model/OutboundThirdpartyTransactionModel.test.js @@ -166,27 +166,7 @@ describe('thirdpartyTransactionModel', () => { }; // manually invoke transition handler - model.onGetThirdPartyTransaction() - .then(() => { - // subscribe should be called only once - expect(cache.subscribe).toBeCalledTimes(1); - - // subscribe should be done to proper notificationChannel - expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); - - // check invocation of request.getThirdpartyRequestsTransactions - expect(ThirdpartyRequests.__getThirdpartyRequestsTransactions).toBeCalledWith(data.transactionRequestId, null); - - // check that this.context.data is updated - expect(model.context.data).toEqual({ - 'transactionId': '5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5', - 'transactionRequestState': 'RECEIVED', - - // current state will be updated by onAfterTransition which isn't called - // when manual invocation of transition handler happens - currentState: 'getTransaction' - }); - }); + model.onGetThirdPartyTransaction(); // ensure handler wasn't called before publishing the message expect(handler).not.toBeCalled(); @@ -201,6 +181,25 @@ describe('thirdpartyTransactionModel', () => { // handler should be called only once expect(handler).toBeCalledTimes(1); + // subscribe should be called only once + expect(cache.subscribe).toBeCalledTimes(1); + + // subscribe should be done to proper notificationChannel + expect(cache.subscribe.mock.calls[0][0]).toEqual(channel); + + // check invocation of request.getThirdpartyRequestsTransactions + expect(ThirdpartyRequests.__getThirdpartyRequestsTransactions).toBeCalledWith(data.transactionRequestId, null); + + // check that this.context.data is updated + expect(model.context.data).toEqual({ + 'transactionId': '5a2ad5dc-4ab1-4a22-8c5b-62f75252a8d5', + 'transactionRequestState': 'RECEIVED', + + // current state will be updated by onAfterTransition which isn't called + // when manual invocation of transition handler happens + currentState: 'getTransaction' + }); + // handler should unsubscribe from notification channel expect(cache.unsubscribe).toBeCalledTimes(1); expect(cache.unsubscribe).toBeCalledWith(subId); diff --git a/src/test/unit/lib/model/common/PersistentStateMachine.test.js b/src/test/unit/lib/model/common/PersistentStateMachine.test.js index d90086ce7..bf618dc96 100644 --- a/src/test/unit/lib/model/common/PersistentStateMachine.test.js +++ b/src/test/unit/lib/model/common/PersistentStateMachine.test.js @@ -169,8 +169,7 @@ describe('PersistentStateMachine', () => { const psm = await PSM.create(data, cache, key, logger, smSpec); checkPSMLayout(psm); - // transition `init` should encounter exception when saving `context.data` - expect(() => psm.init()).rejects.toEqual(new Error('error from cache.set')); + expect(() => psm.saveToCache()).rejects.toEqual(new Error('error from cache.set')); }); }); From 0710da73ace3ac161f3598d40fc42ffd1aca7baf Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Mon, 25 Jan 2021 23:40:45 -0500 Subject: [PATCH 29/29] chore: add new type enums (#242) --- src/InboundServer/api.yaml | 2 ++ src/OutboundServer/api.yaml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/InboundServer/api.yaml b/src/InboundServer/api.yaml index 33af7ed5a..acce42dea 100644 --- a/src/InboundServer/api.yaml +++ b/src/InboundServer/api.yaml @@ -3525,6 +3525,8 @@ components: - ACCOUNT_ID - IBAN - ALIAS + - CONSENT + - THIRD_PARTY_LINK description: >- Below are the allowed values for the enumeration - MSISDN An MSISDN (Mobile Station International Subscriber Directory Number, that is, the diff --git a/src/OutboundServer/api.yaml b/src/OutboundServer/api.yaml index 8e0d34ff6..7afa9ddf3 100644 --- a/src/OutboundServer/api.yaml +++ b/src/OutboundServer/api.yaml @@ -714,6 +714,8 @@ components: - DEVICE - IBAN - ALIAS + - CONSENT + - THIRD_PARTY_LINK description: Below are the allowed values for the enumeration. - MSISDN - An MSISDN (Mobile Station International Subscriber Directory Number, that is, the phone number) is used as reference to a participant. The MSISDN identifier should be in international format according to the [ITU-T E.164 standard](https://www.itu.int/rec/T-REC-E.164/en). Optionally, the MSISDN may be prefixed by a single plus sign, indicating the international prefix. @@ -2275,6 +2277,8 @@ components: - ACCOUNT_ID - IBAN - ALIAS + - CONSENT + - THIRD_PARTY_LINK description: >- Below are the allowed values for the enumeration - MSISDN An MSISDN (Mobile Station International Subscriber Directory Number, that is, the