diff --git a/.circleci/README.md b/.circleci/README.md index 0c9fecb34..4749e7cec 100644 --- a/.circleci/README.md +++ b/.circleci/README.md @@ -31,7 +31,7 @@ This phase contains the following jobs: | GOOGLE_DEV_ENDPOINTS_CREDENTIALS | | | | GOOGLE_GCR_CREDENTIALS | | | | GOOGLE_PROD_ENDPOINTS_CREDENTIALS | | | -| PANTEL_SECRETS_FILE | | | +| GCP_SERVICE_ACCOUNT_SECRETS_FILE | | | | PI_DEV_CLUSTER_CREDENTIALS | | | | PI_PROD_CLUSTER_CREDENTIALS | | | | PROD_PROJECT | | | diff --git a/.circleci/config.yml b/.circleci/config.yml index e51df5f5b..8bf2e5147 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,11 +4,19 @@ jobs: build-test-repo: # machine is needed to run Gradle build and to run docker compose tests machine: - enabled: true + image: circleci/classic:201808-01 + environment: + JAVA_HOME: /usr/lib/jvm/zulu11.31.11-ca-jdk11.0.3-linux_x64 steps: - checkout - - run: + - run: + name: upgrading Java to open-jdk-11 + command: | + # sudo apt update; sudo apt install -y wget + sudo wget https://cdn.azul.com/zulu/bin/zulu11.31.11-ca-jdk11.0.3-linux_x64.tar.gz -O /tmp/zulu11.31.11-ca-jdk11.0.3-linux_x64.tar.gz + sudo tar -zxf /tmp/zulu11.31.11-ca-jdk11.0.3-linux_x64.tar.gz -C /usr/lib/jvm + - run: # checking for merge conflicts and merging locally if none exist name: merging ${CIRCLE_BRANCH} into develop locally command: | @@ -18,8 +26,11 @@ jobs: git checkout develop git merge ${CIRCLE_BRANCH} -m "Merging ${CIRCLE_BRANCH} into develop." # Show the javac version installed. - - run: javac -version - + - run: + name: javac -version + command: | + export PATH=/usr/lib/jvm/zulu11.31.11-ca-jdk11.0.3-linux_x64/bin:$PATH + javac -version - run: name: Pulling Gradle cache command: | @@ -30,11 +41,11 @@ jobs: gsutil cp gs://pi-ostelco-core-gradle-cache/caches.tar.gz ~/caches.tar.gz mkdir -p ~/.gradle/caches/ tar -xzvf ~/caches.tar.gz -C ~/.gradle/caches/ . - # Copying pantel prod secret to locations where it is needed for docker compose tests. + # Copying prime-service-account secret file to locations where it is needed for docker compose tests. - run: - name: Distribute pantel-prod.json secret from env var. + name: Distribute prime-service-account.json secret from env var. command: | - scripts/distribute-pantel-secrets.sh + scripts/distribute-prime-service-account-secrets.sh # run gradle build. Skipping neo4j tests as they fail - run: name: Build entire repo @@ -58,17 +69,17 @@ jobs: name: Generate self signed certs command: | scripts/generate-selfsigned-ssl-certs.sh ocs.dev.ostelco.org - cp certs/ocs.dev.ostelco.org/nginx.crt ocsgw/config/ocs.crt + cp certs/ocs.dev.ostelco.org/nginx.crt ocsgw/cert/ocs.crt scripts/generate-selfsigned-ssl-certs.sh metrics.dev.ostelco.org - cp certs/metrics.dev.ostelco.org/nginx.crt ocsgw/config/metrics.crt - - run: + cp certs/metrics.dev.ostelco.org/nginx.crt ocsgw/cert/metrics.crt + - run: name: Acceptance Tests command: docker-compose up --build --abort-on-container-exit - + - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-feature-branch-commit false + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-feature-branch-commit false code-coverage: environment: @@ -76,20 +87,20 @@ jobs: CODACY_VERSION: 4.0.3 CODACY_JAR_FILE: codacy-coverage-reporter-assembly-latest.jar CODACY_MODULE: com.codacy.CodacyCoverageReporter - + docker: - - image: circleci/openjdk:11-jdk-sid + - image: circleci/openjdk:11.0.1-jdk-node-browsers steps: - - run: + - run: name: Download codacy command: | wget -O ~/${CODACY_JAR_FILE} \ ${CODACY_DOWNLOAD_URL}/${CODACY_VERSION}/codacy-coverage-reporter-${CODACY_VERSION}-assembly.jar - attach_workspace: # Must be absolute path or relative path from working_directory - at: ~/project - + at: ~/project + # the commands below need "CODACY_PROJECT_TOKEN" to be present as (circleci) ENV variable. - run: name: Generate Codacy code-coverage report @@ -97,17 +108,25 @@ jobs: scripts/generate-codacy-coverage.sh - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-feature-branch-commit false - + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-feature-branch-commit false + ### JOBS FOR on-PR-merge-to-dev PIPELINE build-code: machine: - enabled: true + image: circleci/classic:201808-01 + environment: + JAVA_HOME: /usr/lib/jvm/zulu11.31.11-ca-jdk11.0.3-linux_x64 steps: - - checkout + - checkout + - run: + name: upgrading Java to open-jdk-11 + command: | + # sudo apt update; sudo apt install -y wget + sudo wget https://cdn.azul.com/zulu/bin/zulu11.31.11-ca-jdk11.0.3-linux_x64.tar.gz -O /tmp/zulu11.31.11-ca-jdk11.0.3-linux_x64.tar.gz + sudo tar -zxf /tmp/zulu11.31.11-ca-jdk11.0.3-linux_x64.tar.gz -C /usr/lib/jvm - run: name: Pulling Gradle cache command: | @@ -118,6 +137,12 @@ jobs: gsutil cp gs://pi-ostelco-core-gradle-cache/caches.tar.gz ~/caches.tar.gz mkdir -p ~/.gradle/caches/ tar -xzvf ~/caches.tar.gz -C ~/.gradle/caches/ . + # Show the javac version installed. + - run: + name: javac -version + command: | + export PATH=/usr/lib/jvm/zulu11.31.11-ca-jdk11.0.3-linux_x64/bin:$PATH + javac -version - run: name: Gradle Build Prime command: ./gradlew clean prime:build -info -s -x test -x integration @@ -132,17 +157,17 @@ jobs: rm -fr ~/.gradle/caches/*/plugin-resolution/ tar -czvf ~/caches.tar.gz -C ~/.gradle/caches . gsutil cp ~/caches.tar.gz gs://pi-ostelco-core-gradle-cache - + - persist_to_workspace: root: ~/project/ paths: - . - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-PR-merge-to-dev false - + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-PR-merge-to-dev false + build-image: docker: - image: google/cloud-sdk:latest @@ -159,7 +184,7 @@ jobs: gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json - attach_workspace: # Must be absolute path or relative path from working_directory - at: ~/project + at: ~/project # starts a remote docker environment to run docker commands - setup_remote_docker @@ -172,7 +197,7 @@ jobs: cd prime docker build -t eu.gcr.io/pi-ostelco-dev/prime:$TAG . docker push eu.gcr.io/pi-ostelco-dev/prime:$TAG - + # - run: # name: build OCSGW docker image and push image to GCR # command: | @@ -182,47 +207,52 @@ jobs: # docker build -t eu.gcr.io/pi-ostelco-dev/ocsgw:$TAG . # docker push eu.gcr.io/pi-ostelco-dev/ocsgw:$TAG - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-PR-merge-to-dev false - + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-PR-merge-to-dev false + update-dev-endpoints: docker: - - image: eu.gcr.io/pi-ostelco-dev/python-gcloud - steps: + - image: eu.gcr.io/pi-ostelco-prod/python-gcloud + steps: - checkout - run: name: update endpoints spec - command: | + command: | export CLOUDSDK_CORE_PROJECT=${DEV_PROJECT} echo $GOOGLE_DEV_ENDPOINTS_CREDENTIALS > ${HOME}/gcloud-service-key.json gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json - + + sed -i 's/GCP_PROJECT_ID/'${DEV_PROJECT}'/g' prime/infra/dev/ocs-api.yaml + sed -i 's/GCP_PROJECT_ID/'${DEV_PROJECT}'/g' prime/infra/dev/metrics-api.yaml + python -m grpc_tools.protoc --include_imports --include_source_info --proto_path=ocs-grpc-api/src/main/proto --descriptor_set_out=ocs_descriptor.pb ocs.proto python -m grpc_tools.protoc --include_imports --include_source_info --proto_path=analytics-grpc-api/src/main/proto --descriptor_set_out=metrics_descriptor.pb prime_metrics.proto - gcloud endpoints services deploy ocs_descriptor.pb prime/infra/new-dev/ocs-api.yaml - gcloud endpoints services deploy metrics_descriptor.pb prime/infra/new-dev/metrics-api.yaml - gcloud endpoints services deploy prime/infra/new-dev/prime-client-api.yaml - + gcloud endpoints services deploy ocs_descriptor.pb prime/infra/dev/ocs-api.yaml + gcloud endpoints services deploy metrics_descriptor.pb prime/infra/dev/metrics-api.yaml + gcloud endpoints services deploy prime/infra/dev/prime-customer-api.yaml + gcloud endpoints services deploy prime/infra/dev/prime-webhooks.yaml + gcloud endpoints services deploy prime/infra/dev/prime-houston-api.yaml + - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-PR-merge-to-dev false - + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-PR-merge-to-dev false + deploy-to-dev: working_directory: ~/project docker: - - image: praqma/gcloud-kubectl-helm:v2.8.1 - environment: - PROJECT: pi-ostelco-dev - CLUSTER: pi-dev - ZONE: europe-west1-c - SERVICE_ACCOUNT: terraform-dev-cluster@pi-ostelco-dev.iam.gserviceaccount.com + - image: praqma/gcloud-kubectl-helm:v2.11.0 + environment: + PROJECT: pi-ostelco-dev + CLUSTER: pi-dev + ZONE: europe-west1-c + SERVICE_ACCOUNT: terraform-dev-cluster@pi-ostelco-dev.iam.gserviceaccount.com steps: - checkout - + - run: name: deploy prime to the dev cluster command: | @@ -230,75 +260,52 @@ jobs: /authenticate.bash helm repo add ostelco https://storage.googleapis.com/pi-ostelco-helm-charts-repo/ helm repo update - helm upgrade prime ostelco/prime --install --namespace dev \ + helm upgrade prime ostelco/prime --version 0.6.1 --install --namespace dev \ -f .circleci/prime-dev-values.yaml \ - --set prime.env.STRIPE_API_KEY=${STRIPE_API_KEY} \ - --set prime.tag=${CIRCLE_SHA1:0:9} \ - --set firebaseServiceAccount=${PANTEL_SECRETS_FILE} - - - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-PR-merge-to-dev false - - create-PR-to-master: - working_directory: ~/project + --set prime.tag=${CIRCLE_SHA1:0:9} - docker: - - image: eu.gcr.io/pi-ostelco-dev/github-hub:2.5.0 - - steps: - - checkout - run: - name: create PR to merge develop into master - command: | - export PRIME_TAG=${CIRCLE_SHA1:0:9} - cd .circleci - ./substitute_prime_tag.sh - git config --global user.email "${GIT_USER_EMAIL}" - git config --global user.name "${GIT_USER_NAME}" - git add prime-prod-values.yaml - git commit -m "[ci skip] updating prime image tag to the latest built image." - git push https://${GITHUB_USER}:${GITHUB_TOKEN}@github.com/ostelco/ostelco-core.git develop - hub pull-request -m "merging develop into master" -b master - - - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-PR-merge-to-dev false + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-PR-merge-to-dev false ### JOBS FOR on-PR-merge-to-master PIPELINE update-prod-endpoints: docker: - - image: eu.gcr.io/pi-ostelco-dev/python-gcloud - steps: + - image: eu.gcr.io/pi-ostelco-prod/python-gcloud + steps: - checkout - run: name: update endpoints spec - command: | + command: | export CLOUDSDK_CORE_PROJECT=${PROD_PROJECT} echo $GOOGLE_PROD_ENDPOINTS_CREDENTIALS > ${HOME}/gcloud-service-key.json gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json - + + sed -i 's/GCP_PROJECT_ID/'${PROD_PROJECT}'/g' prime/infra/prod/ocs-api.yaml + sed -i 's/GCP_PROJECT_ID/'${PROD_PROJECT}'/g' prime/infra/prod/metrics-api.yaml + python -m grpc_tools.protoc --include_imports --include_source_info --proto_path=ocs-grpc-api/src/main/proto --descriptor_set_out=ocs_descriptor.pb ocs.proto python -m grpc_tools.protoc --include_imports --include_source_info --proto_path=analytics-grpc-api/src/main/proto --descriptor_set_out=metrics_descriptor.pb prime_metrics.proto - gcloud endpoints services deploy ocs_descriptor.pb prime/infra/new-prod/ocs-api.yaml - gcloud endpoints services deploy metrics_descriptor.pb prime/infra/new-prod/metrics-api.yaml - gcloud endpoints services deploy prime/infra/new-prod/prime-client-api.yaml - + gcloud endpoints services deploy ocs_descriptor.pb prime/infra/prod/ocs-api.yaml + gcloud endpoints services deploy metrics_descriptor.pb prime/infra/prod/metrics-api.yaml + gcloud endpoints services deploy prime/infra/prod/prime-customer-api.yaml + gcloud endpoints services deploy prime/infra/prod/prime-webhooks.yaml + gcloud endpoints services deploy prime/infra/prod/prime-houston-api.yaml + - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-PR-merge-to-master false - + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-PR-merge-to-master false + deploy-to-prod: docker: - - image: praqma/gcloud-kubectl-helm:v2.8.1 - environment: - PROJECT: pi-ostelco-prod - CLUSTER: pi-prod - ZONE: europe-west1-c - SERVICE_ACCOUNT: terraform-manage-cluster-from@pi-ostelco-prod.iam.gserviceaccount.com + - image: praqma/gcloud-kubectl-helm:v2.11.0 + environment: + PROJECT: pi-ostelco-prod + CLUSTER: pi-prod + ZONE: europe-west1-c + SERVICE_ACCOUNT: terraform-manage-cluster-from@pi-ostelco-prod.iam.gserviceaccount.com steps: - checkout @@ -306,20 +313,17 @@ jobs: name: deploy prime to the prod cluster command: | export GOOGLE_CREDENTIALS=${PI_PROD_CLUSTER_CREDENTIALS} - #export TAG=$(git rev-parse --short=9 origin/circleci-dev) # fragile, gives latest develop commit but that may not be the correct tag! /authenticate.bash helm repo add ostelco https://storage.googleapis.com/pi-ostelco-helm-charts-repo/ helm repo update - helm upgrade prime ostelco/prime --version 0.3.1 --install --namespace prod \ - -f .circleci/prime-prod-values.yaml \ - --set prime.env.STRIPE_API_KEY=${STRIPE_API_KEY} \ - --set firebaseServiceAccount=${PANTEL_SECRETS_FILE} + helm upgrade prime ostelco/prime --version 0.6.1 --install --namespace prod \ + -f .circleci/prime-prod-values.yaml --set-string canary.tag=${CIRCLE_SHA1:0:9} - run: - name: notify slack on failure - when: on_fail - command: .circleci/notify-slack.sh on-PR-merge-to-master false - + name: notify slack on failure + when: on_fail + command: .circleci/notify-slack.sh on-PR-merge-to-master false + workflows: version: 2 on-feature-branch-commit: @@ -327,38 +331,35 @@ workflows: - build-test-repo: filters: branches: - only: /feature/.*/ + only: /feature/.*/ - code-coverage: - requires: - - build-test-repo + requires: + - build-test-repo on-PR-merge-to-dev: jobs: - - build-code: - filters: - branches: - only: - - develop - - build-image: - requires: - - build-code - - update-dev-endpoints: - requires: - - build-image - - deploy-to-dev: - requires: - - update-dev-endpoints -# - create-PR-to-master: -# requires: -# - deploy-to-dev + - build-code: + filters: + branches: + only: + - develop + - build-image: + requires: + - build-code + - update-dev-endpoints: + requires: + - build-image + - deploy-to-dev: + requires: + - update-dev-endpoints deploy-to-prod: jobs: - - update-prod-endpoints: - filters: - branches: - only: - - master - - deploy-to-prod: - requires: - - update-prod-endpoints \ No newline at end of file + - update-prod-endpoints: + filters: + branches: + only: + - master + - deploy-to-prod: + requires: + - update-prod-endpoints diff --git a/.circleci/prime-dev-values.yaml b/.circleci/prime-dev-values.yaml index 7d9c67148..ecc839b70 100644 --- a/.circleci/prime-dev-values.yaml +++ b/.circleci/prime-dev-values.yaml @@ -2,44 +2,159 @@ # This is a YAML-formatted file. # Declare variables to be passed into your templates. -replicaCount: 1 -firebaseServiceAccount: "" - +replicaCount: 2 + +dnsPrefix: "" +dnsSuffix: ".dev.oya.world" + +podAutoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 70 + +cronjobs: + extractor: + enabled: true + image: eu.gcr.io/pi-ostelco-dev/bq-metrics-extractor + tag: "1.3.212.1.0-2d41d62b-dev" + dataset_project: pi-ostelco-dev + shredder: + enabled: true + image: eu.gcr.io/pi-ostelco-dev/scaninfo-shredder + tag: "1.0.0-6052932a-dev" + dataset_project: pi-ostelco-dev + dev: true prime: image: eu.gcr.io/pi-ostelco-dev/prime - tag: 2f47ab570 + tag: 9ca9ca976 pullPolicy: Always - env: - FIREBASE_ROOT_PATH: dev_new + configDataBucket: "gs://pi-ostelco-dev-prime-files/dev" + + env: + FIREBASE_ROOT_PATH: dev NEO4J_HOST: neo4j-neo4j.neo4j.svc.cluster.local - STRIPE_API_KEY: "" - DATA_TRAFFIC_TOPIC: "data-traffic" - PURCHASE_INFO_TOPIC: "purchase-info" - ACTIVE_USERS_TOPIC: "active-users" + SLACK_CHANNEL: prime-alerts + DATASTORE_NAMESPACE: dev + DATA_TRAFFIC_TOPIC: data-traffic + PURCHASE_INFO_TOPIC: purchase-info + ACTIVE_USERS_TOPIC: active-users + STRIPE_EVENT_TOPIC: stripe-event + STRIPE_EVENT_STORE_SUBSCRIPTION: stripe-event-store-sub + STRIPE_EVENT_REPORT_SUBSCRIPTION: stripe-event-report-sub + GCP_PROJECT_ID: pi-ostelco-dev + ACTIVATE_TOPIC_ID: ocs-activate + CCR_SUBSCRIPTION_ID: ocs-ccr-sub + GOOGLE_APPLICATION_CREDENTIALS: /secret/prime-service-account.json + MY_INFO_API_URI: https://myinfosgstg.api.gov.sg/test/v2 + MY_INFO_API_REALM: dev + MY_INFO_REDIRECT_URI: https://dl-dev.oya.world/links/myinfo + + secretVolumes: + - secretName: "prime-sa-key" + containerMountPath: "/secret" + - secretName: "simmgr-test-secrets" + containerMountPath: "/certs" + secretKey: idemiaClientCert + secretPath: idemia-client-cert.jks + - secretName: "scaninfo-keysets" + containerMountPath: "/scaninfo-keysets" + + envFromSecret: + - name: SLACK_WEBHOOK_URI + secretName: slack-secrets + secretKey: slackWebHookUri + - name: STRIPE_API_KEY + secretName: stripe-secrets + secretKey: stripeApiKey + - name: STRIPE_ENDPOINT_SECRET + secretName: stripe-secrets + secretKey: stripeEndpointSecret + - name: SCANINFO_STORAGE_BUCKET + secretName: scaninfo-secrets + secretKey: bucketName + - name: SCANINFO_MASTERKEY_URI + secretName: scaninfo-keys + secretKey: masterKeyUri + - name: JUMIO_API_TOKEN + secretName: jumio-secrets + secretKey: apiToken + - name: JUMIO_API_SECRET + secretName: jumio-secrets + secretKey: apiSecret + - name: MY_INFO_API_CLIENT_ID + secretName: myinfo-secrets + secretKey: apiClientId + - name: MY_INFO_API_CLIENT_SECRET + secretName: myinfo-secrets + secretKey: apiClientSecret + - name: MY_INFO_SERVER_PUBLIC_KEY + secretName: myinfo-secrets + secretKey: serverPublicKey + - name: MY_INFO_CLIENT_PRIVATE_KEY + secretName: myinfo-secrets + secretKey: clientPrivateKey + - name: DB_USER + secretName: simmgr-test-secrets + secretKey: dbUser + - name: DB_PASSWORD + secretName: simmgr-test-secrets + secretKey: dbPassword + - name: DB_URL + secretName: simmgr-test-secrets + secretKey: dbUrl + - name: WG2_USER + secretName: simmgr-test-secrets + secretKey: wg2User + - name: WG2_API_KEY + secretName: simmgr-test-secrets + secretKey: wg2ApiKey + - name: WG2_ENDPOINT + secretName: simmgr-test-secrets + secretKey: wg2Endpoint + - name: ES2PLUS_ENDPOINT + secretName: simmgr-test-secrets + secretKey: es2plusEndpoint + - name: ES9PLUS_ENDPOINT + secretName: simmgr-test-secrets + secretKey: es9plusEndpoint + - name: FUNCTION_REQUESTER_IDENTIFIER + secretName: simmgr-test-secrets + secretKey: functionRequesterIdentifier + - name: MANDRILL_API_KEY + secretName: mandrill-secrets + secretKey: mandrillApiKey + ports: - 8080 - 8081 - 8082 - 8083 resources: - limits: - cpu: 200m - memory: 350Mi requests: cpu: 100m - memory: 200Mi + memory: 300Mi livenessProbe: {} - # path: / - # port: 8081 readinessProbe: {} - # path: / - # port: 8081 annotations: prometheus.io/scrape: 'true' prometheus.io/path: '/prometheus-metrics' prometheus.io/port: '8081' + +canary: {} + # weight: 25 + # headers: # only route requests with these headers to the canary service + # x-mode: canary + # tag: e449ed672 + +cloudsqlProxy: + enabled: true + instanceConnectionName: "pi-ostelco-dev:europe-west1:sim-manager" + secretName: "prime-sa-key" + secretKey: "prime-service-account.json" + esp: image: gcr.io/endpoints-release/endpoints-runtime tag: 1 @@ -48,74 +163,115 @@ esp: ocsEsp: enabled: true env: {} - endpointAddress: ocs.new.dev.ostelco.org + endpointAddress: ocs.dev.oya.world ports: - - 9000 - - 8443 - + http2_port: 9000 + ssl_port: 8443 + secretVolumes: + - secretName: dev-oya-tls + containerMountPath: /etc/nginx/ssl + type: ssl apiEsp: enabled: true env: {} - endpointAddress: api.new.dev.ostelco.org + endpointAddress: api.dev.oya.world ports: - - 9002 - - 443 + http2_port: 9002 metricsEsp: enabled: true env: {} - endpointAddress: metrics.new.dev.ostelco.org + endpointAddress: metrics.dev.oya.world + ports: + http2_port: 9004 + ssl_port: 9443 + secretVolumes: + - secretName: dev-oya-tls + containerMountPath: /etc/nginx/ssl + type: ssl + +alvinApiEsp: + enabled: true + env: {} + endpointAddress: alvin-api.dev.oya.world ports: - - 9004 - - 9443 - + http_port: 9008 + +houstonApiEsp: + enabled: true + env: {} + endpointAddress: houston-api.dev.oya.world + ports: + http_port: 9006 services: - prime: - name: prime-service + ocs: + name: ocs type: LoadBalancer port: 443 targetPort: 8443 - portName: grpc - # loadBalancerIP: x.y.z.n + portName: grpc + # host: ocs # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + # grpcOrHttp2: true api: - name: prime-api + name: api + type: ClusterIP + port: 80 + targetPort: 9002 + portName: http + host: api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + grpcOrHttp2: true + ambassadorMappingOptions: + timeout_ms: 600000 + metrics: + name: metrics type: LoadBalancer port: 443 - targetPort: 443 - portName: https - # loadBalancerIP: x.y.z.n - metrics: - name: prime-metrics - type: LoadBalancer - port: 443 - targetPort: 9443 + targetPort: 9443 portName: grpc # loadBalancerIP: x.y.z.n - -ingress: - enabled: false - annotations: {} - # kubernetes.io/ingress.class: nginx - path: / - hosts: - - prime.local - tls: [] - # - secretName: chart-example-tls - # hosts: - # - chart-example.local + # host: metrics # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + # grpcOrHttp2: true + prime-houston-api: + name: houston-api + type: ClusterIP + port: 80 + targetPort: 9006 + portName: http + host: houston-api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + prime-alvin-api: + name: alvin-api + type: ClusterIP + port: 80 + targetPort: 9008 + portName: http + host: alvin-api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + ambassadorMappingOptions: + timeout_ms: 600000 + dwadmin-service: + name: dwadmin-service + type: ClusterIP + port: 8081 + targetPort: 8081 + portName: http + smdpplus: + name: smdpplus + type: ClusterIP + port: 80 + targetPort: 8080 + portName: http + host: smdpplus + clientCert: true + caCert: smdp-cacert.dev # secretname.namespace certs: enabled: true dnsProvider: dev-clouddns - issuer: letsencrypt-production # or letsencrypt-staging - apiDns: - - api.new.dev.ostelco.org - ocsDns: - - ocs.new.dev.ostelco.org - metricsDns: - - metrics.new.dev.ostelco.org + issuer: letsencrypt-production + tlsSecretName: dev-oya-tls + hosts: + - '*.dev.oya.world' disruptionBudget: enabled: false diff --git a/.circleci/prime-prod-values-template.yaml b/.circleci/prime-prod-values-template.yaml deleted file mode 100644 index 59615a8c3..000000000 --- a/.circleci/prime-prod-values-template.yaml +++ /dev/null @@ -1,127 +0,0 @@ -# PROD values for prime. -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. - -replicaCount: 1 -firebaseServiceAccount: "" - -prime: - image: eu.gcr.io/pi-ostelco-dev/prime - tag: ${PRIME_TAG} - pullPolicy: Always - env: - FIREBASE_ROOT_PATH: dev_new - NEO4J_HOST: neo4j-neo4j.neo4j.svc.cluster.local - STRIPE_API_KEY: "" - DATA_TRAFFIC_TOPIC: "data-traffic" - PURCHASE_INFO_TOPIC: "purchase-info" - ACTIVE_USERS_TOPIC: "active-users" - ports: - - 8080 - - 8081 - - 8082 - - 8083 - resources: - limits: - cpu: 200m - memory: 350Mi - requests: - cpu: 100m - memory: 200Mi - livenessProbe: {} - # path: / - # port: 8081 - readinessProbe: {} - # path: / - # port: 8081 - annotations: - prometheus.io/scrape: 'true' - prometheus.io/path: '/prometheus-metrics' - prometheus.io/port: '8081' - -esp: - image: gcr.io/endpoints-release/endpoints-runtime - tag: 1 - pullPolicy: IfNotPresent - -ocsEsp: - enabled: true - env: {} - endpointAddress: prod-ocs.new.dev.ostelco.org - ports: - - 9000 - - 8443 - - -apiEsp: - enabled: true - env: {} - endpointAddress: prod-api.new.dev.ostelco.org - ports: - - 9002 - - 443 - -metricsEsp: - enabled: true - env: {} - endpointAddress: prod-metrics.new.dev.ostelco.org - ports: - - 9004 - - 9443 - - -services: - prime: - name: prime-service - type: LoadBalancer - port: 443 - targetPort: 8443 - portName: grpc - # loadBalancerIP: x.y.z.n - api: - name: prime-api - type: LoadBalancer - port: 443 - targetPort: 443 - portName: https - # loadBalancerIP: x.y.z.n - metrics: - name: prime-metrics - type: LoadBalancer - port: 443 - targetPort: 9443 - portName: grpc - # loadBalancerIP: x.y.z.n - -ingress: - enabled: false - annotations: {} - # kubernetes.io/ingress.class: nginx - path: / - hosts: - - prime.local - tls: [] - # - secretName: chart-example-tls - # hosts: - # - chart-example.local - -certs: - enabled: true - dnsProvider: dev-clouddns - issuer: letsencrypt-production # or letsencrypt-staging - apiDns: - - prod-api.new.dev.ostelco.org - ocsDns: - - prod-ocs.new.dev.ostelco.org - metricsDns: - - prod-metrics.new.dev.ostelco.org - -disruptionBudget: - enabled: false - minAvailable: 1 - -nodeSelector: {} - -tolerations: [] - -affinity: {} \ No newline at end of file diff --git a/.circleci/prime-prod-values.yaml b/.circleci/prime-prod-values.yaml index 19bc9752e..15943f8e9 100644 --- a/.circleci/prime-prod-values.yaml +++ b/.circleci/prime-prod-values.yaml @@ -1,21 +1,130 @@ # PROD values for prime. # This is a YAML-formatted file. -# Declare variables to be passed into your templates. +# Declare variables to be passed into your templates. -replicaCount: 1 -firebaseServiceAccount: "" +replicaCount: 3 + +dnsPrefix: "" +dnsSuffix: ".oya.world" + +podAutoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 6 + targetCPUUtilizationPercentage: 70 + +cronjobs: + extractor: + enabled: false + image: eu.gcr.io/pi-ostelco-dev/bq-metrics-extractor + tag: "1.3.212.1.0-2d41d62b-dev" + dataset_project: pi-ostelco-prod + shredder: + enabled: false + image: eu.gcr.io/pi-ostelco-dev/scaninfo-shredder + tag: "1.0.0-6052932a-dev" + dataset_project: pi-ostelco-prod + dev: false prime: image: eu.gcr.io/pi-ostelco-dev/prime - tag: 5990ea1d6 + tag: 658618fc1 # stable tag pullPolicy: Always - env: - FIREBASE_ROOT_PATH: dev_new + configDataBucket: "gs://pi-ostelco-prod-prime-files/" + + env: + FIREBASE_ROOT_PATH: v2 NEO4J_HOST: neo4j-neo4j.neo4j.svc.cluster.local - STRIPE_API_KEY: "" - DATA_TRAFFIC_TOPIC: "data-traffic" - PURCHASE_INFO_TOPIC: "purchase-info" - ACTIVE_USERS_TOPIC: "active-users" + SLACK_CHANNEL: prime-alerts + DATASTORE_NAMESPACE: "" + DATA_TRAFFIC_TOPIC: data-traffic + PURCHASE_INFO_TOPIC: purchase-info + ACTIVE_USERS_TOPIC: active-users + STRIPE_EVENT_TOPIC: stripe-event + STRIPE_EVENT_STORE_SUBSCRIPTION: stripe-event-store-sub + STRIPE_EVENT_REPORT_SUBSCRIPTION: stripe-event-report-sub + GCP_PROJECT_ID: pi-ostelco-prod + ACTIVATE_TOPIC_ID: ocs-activate + CCR_SUBSCRIPTION_ID: ocs-ccr-sub + GOOGLE_APPLICATION_CREDENTIALS: /secret/prime-service-account.json + MY_INFO_API_URI: https://myinfosg.api.gov.sg/v2 + MY_INFO_API_REALM: prod + MY_INFO_REDIRECT_URI: https://dl.oya.world/links/myinfo + + secretVolumes: + - secretName: "prime-sa-key" + containerMountPath: "/secret" + - secretName: "simmgr-secrets" + containerMountPath: "/certs" + secretKey: idemiaClientCert + secretPath: idemia-client-cert.jks + - secretName: "scaninfo-keysets" + containerMountPath: "/scaninfo-keysets" + + envFromSecret: + - name: SLACK_WEBHOOK_URI + secretName: slack-secrets + secretKey: slackWebHookUri + - name: STRIPE_API_KEY + secretName: stripe-secrets + secretKey: stripeApiKey + - name: STRIPE_ENDPOINT_SECRET + secretName: stripe-secrets + secretKey: stripeEndpointSecret + - name: SCANINFO_STORAGE_BUCKET + secretName: scaninfo-secrets + secretKey: bucketName + - name: SCANINFO_MASTERKEY_URI + secretName: scaninfo-keys + secretKey: masterKeyUri + - name: JUMIO_API_TOKEN + secretName: jumio-secrets + secretKey: apiToken + - name: JUMIO_API_SECRET + secretName: jumio-secrets + secretKey: apiSecret + - name: MY_INFO_API_CLIENT_ID + secretName: myinfo-secrets + secretKey: apiClientId + - name: MY_INFO_API_CLIENT_SECRET + secretName: myinfo-secrets + secretKey: apiClientSecret + - name: MY_INFO_SERVER_PUBLIC_KEY + secretName: myinfo-secrets + secretKey: serverPublicKey + - name: MY_INFO_CLIENT_PRIVATE_KEY + secretName: myinfo-secrets + secretKey: clientPrivateKey + - name: DB_USER + secretName: simmgr-secrets + secretKey: dbUser + - name: DB_PASSWORD + secretName: simmgr-secrets + secretKey: dbPassword + - name: DB_URL + secretName: simmgr-secrets + secretKey: dbUrl + - name: WG2_USER + secretName: simmgr-secrets + secretKey: wg2User + - name: WG2_API_KEY + secretName: simmgr-secrets + secretKey: wg2ApiKey + - name: WG2_ENDPOINT + secretName: simmgr-secrets + secretKey: wg2Endpoint + - name: ES2PLUS_ENDPOINT + secretName: simmgr-secrets + secretKey: es2plusEndpoint + - name: ES9PLUS_ENDPOINT + secretName: simmgr-secrets + secretKey: es9plusEndpoint + - name: FUNCTION_REQUESTER_IDENTIFIER + secretName: simmgr-secrets + secretKey: functionRequesterIdentifier + - name: MANDRILL_API_KEY + secretName: mandrill-secrets + secretKey: mandrillApiKey ports: - 8080 @@ -25,7 +134,7 @@ prime: resources: limits: cpu: 200m - memory: 350Mi + memory: 400Mi requests: cpu: 100m memory: 200Mi @@ -40,6 +149,19 @@ prime: prometheus.io/path: '/prometheus-metrics' prometheus.io/port: '8081' + +canary: + # weight: 25 + headers: # only route requests with these headers to the canary service + x-mode: canary + tag: "" + +cloudsqlProxy: + enabled: true + instanceConnectionName: "pi-ostelco-prod:europe-west1:sim-manager" + secretName: "prime-sa-key" + secretKey: "prime-service-account.json" + esp: image: gcr.io/endpoints-release/endpoints-runtime tag: 1 @@ -48,77 +170,119 @@ esp: ocsEsp: enabled: true env: {} - endpointAddress: prod-ocs.new.dev.ostelco.org + endpointAddress: ocs.oya.world ports: - - 9000 - - 8443 - + http2_port: 9000 + ssl_port: 8443 + secretVolumes: + - secretName: prod-oya-tls + containerMountPath: /etc/nginx/ssl + type: ssl apiEsp: enabled: true env: {} - endpointAddress: prod-api.new.dev.ostelco.org + endpointAddress: api.oya.world ports: - - 9002 - - 443 + http2_port: 9002 metricsEsp: enabled: true env: {} - endpointAddress: prod-metrics.new.dev.ostelco.org + endpointAddress: metrics.oya.world ports: - - 9004 - - 9443 - + http2_port: 9004 + ssl_port: 9443 + secretVolumes: + - secretName: prod-oya-tls + containerMountPath: /etc/nginx/ssl + type: ssl + +alvinApiEsp: + enabled: true + env: {} + endpointAddress: alvin-api.oya.world + ports: + http_port: 9008 + +houstonApiEsp: + enabled: true + env: {} + endpointAddress: houston-api.oya.world + ports: + http_port: 9006 services: - prime: - name: prime-service + ocs: + name: ocs type: LoadBalancer port: 443 targetPort: 8443 - portName: grpc - # loadBalancerIP: x.y.z.n + portName: grpc + api: - name: prime-api - type: LoadBalancer - port: 443 - targetPort: 443 - portName: https - # loadBalancerIP: x.y.z.n - metrics: - name: prime-metrics + name: api + type: ClusterIP + port: 80 + targetPort: 9002 + portName: http + host: api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + grpcOrHttp2: true + ambassadorMappingOptions: + timeout_ms: 600000 + metrics: + name: metrics type: LoadBalancer port: 443 - targetPort: 9443 + targetPort: 9443 portName: grpc - # loadBalancerIP: x.y.z.n + + prime-houston-api: + name: houston-api + type: ClusterIP + port: 80 + targetPort: 9006 + portName: http + host: houston-api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + prime-alvin-api: + name: alvin-api + type: ClusterIP + port: 80 + targetPort: 9008 + portName: http + host: alvin-api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + ambassadorMappingOptions: + timeout_ms: 600000 + dwadmin-service: + name: dwadmin-service + type: ClusterIP + port: 8081 + targetPort: 8081 + portName: http + smdpplus: + name: smdpplus + type: ClusterIP + port: 80 + targetPort: 8080 + portName: http + host: smdpplus + clientCert: true + caCert: smdp-cacert.prod # secretname.namespace ingress: enabled: false - annotations: {} - # kubernetes.io/ingress.class: nginx - path: / - hosts: - - prime.local - tls: [] - # - secretName: chart-example-tls - # hosts: - # - chart-example.local + certs: enabled: true dnsProvider: dev-clouddns issuer: letsencrypt-production # or letsencrypt-staging - apiDns: - - prod-api.new.dev.ostelco.org - ocsDns: - - prod-ocs.new.dev.ostelco.org - metricsDns: - - prod-metrics.new.dev.ostelco.org + tlsSecretName: prod-oya-tls + hosts: + - '*.oya.world' disruptionBudget: - enabled: false + enabled: true minAvailable: 1 nodeSelector: {} diff --git a/.circleci/substitute_prime_tag.sh b/.circleci/substitute_prime_tag.sh deleted file mode 100755 index b211b6733..000000000 --- a/.circleci/substitute_prime_tag.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -# This script is used to inject an image tag for PRIME taken from the PRIME_TAG environment variable -# into the prime-prod-values.yaml (the values file used for production deployment of the PRIME helm chart). -# The script is used in the pipeline after new PRIME image is created and before making a PR from develop into master. - -# This script should NOT be used to inject secrets into the values file as this file is version controlled in git. - -rm -f prime-prod-values.yaml temp.yml -( echo "cat <prime-prod-values.yaml"; - cat prime-prod-values-template.yaml; - echo "EOF"; -) >temp.yml -. temp.yml \ No newline at end of file diff --git a/.gcloudignore b/.gcloudignore index 03b953486..4ddc37920 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -5,4 +5,4 @@ #!include:.gitignore -pantel-prod.json \ No newline at end of file +prime-service-account.json \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index c8ca266a8..c2501bdd4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,8 +10,11 @@ cache: install: echo "skip 'gradle assemble' step" +jdk: openjdk11 + # TODO vihang: fix neo4j-store:test -script: ./gradlew clean build -info --stacktrace -x neo4j-store:test -x integration +script: + - ./gradlew clean build -info --stacktrace -x neo4j-store:test -x integration before_cache: - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock diff --git a/README.md b/README.md index a069841c2..499984443 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Kotlin version badge](https://img.shields.io/badge/kotlin-1.2.71-blue.svg)](http://kotlinlang.org/) +[![Kotlin version badge](https://img.shields.io/badge/kotlin-1.3.30-blue.svg)](http://kotlinlang.org/) [![Prime version](https://img.shields.io/github/tag/ostelco/ostelco-core.svg)](https://github.com/ostelco/ostelco-core/tags) [![GitHub license](https://img.shields.io/github/license/ostelco/ostelco-core.svg)](https://github.com/ostelco/ostelco-core/blob/master/LICENSE) @@ -12,6 +12,7 @@ # ostelco-core + Mono Repository for core protocols and services around a OCS/BSS for packet data. For each service please see the individual Readme.md files. * [The big picture (diagram) of current work-flow](https://github.com/ostelco/ostelco-docs/blob/master/the-current-work-flow.md) @@ -29,9 +30,9 @@ Mono Repository for core protocols and services around a OCS/BSS for packet data * [ocsgw](./ocsgw/README.md) * [ostelco-lib](./ostelco-lib/README.md) * [payment-processor](./payment-processor/README.md) - * [prime-client-api](./prime-client-api/README.md) * [prime](./prime/README.md) * [infra](./prime/infra/README.md) * [pseudonym-server](./pseudonym-server/README.md) * [seagull](./seagull/README.md) * [neo4j-admin-tools](./tools/neo4j-admin-tools/README.md) + diff --git a/acceptance-tests/Dockerfile b/acceptance-tests/Dockerfile index d695fff0f..15dd05302 100644 --- a/acceptance-tests/Dockerfile +++ b/acceptance-tests/Dockerfile @@ -1,6 +1,6 @@ -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" RUN apt-get update \ && apt-get install -y --no-install-recommends netcat \ diff --git a/acceptance-tests/build.gradle b/acceptance-tests/build.gradle index 8ee2826c8..6bf2857a3 100644 --- a/acceptance-tests/build.gradle +++ b/acceptance-tests/build.gradle @@ -1,7 +1,7 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "application" - id "com.github.johnrengelman.shadow" version "4.0.1" + id "com.github.johnrengelman.shadow" version "5.0.0" } dependencies { @@ -9,15 +9,22 @@ dependencies { implementation ("org.jetbrains.kotlin:kotlin-test:$kotlinVersion") { exclude module: 'kotlin-stdlib-common' } + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinXCoroutinesVersion" implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" - implementation project(":prime-client-api") + implementation project(":prime-customer-api") implementation project(':diameter-test') + implementation project(':ocs-grpc-api') + implementation "com.google.cloud:google-cloud-pubsub:$googleCloudVersion" + implementation "com.stripe:stripe-java:$stripeVersion" - implementation 'io.jsonwebtoken:jjwt:0.9.1' - // tests fail when updated to 2.27 - implementation "org.glassfish.jersey.media:jersey-media-json-jackson:2.25.1" + implementation ("io.jsonwebtoken:jjwt:$jjwtVersion") { + exclude group: "com.fasterxml.jackson.core", module:"jackson-databind" + } + + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" + implementation 'org.zalando.phrs:jersey-media-json-gson:0.1' implementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" diff --git a/acceptance-tests/config/.gitignore b/acceptance-tests/config/.gitignore index bf045303f..3b858adea 100644 --- a/acceptance-tests/config/.gitignore +++ b/acceptance-tests/config/.gitignore @@ -1 +1 @@ -pantel-prod.json \ No newline at end of file +prime-service-account.json \ No newline at end of file diff --git a/acceptance-tests/script/wait.sh b/acceptance-tests/script/wait.sh index 1d4dcbae6..4f73e7cd4 100755 --- a/acceptance-tests/script/wait.sh +++ b/acceptance-tests/script/wait.sh @@ -2,7 +2,7 @@ set -e -echo "AT waiting ocsgw to launch on 8082..." +echo "AT waiting ocsgw to launch on 3868..." while ! nc -z 172.16.238.3 3868; do sleep 0.1 # wait for 1/10 of the second before check again @@ -18,20 +18,4 @@ done echo "Prime launched" -java -cp '/acceptance-tests.jar' org.junit.runner.JUnitCore \ - org.ostelco.at.okhttp.GetPseudonymsTest \ - org.ostelco.at.okhttp.GetProductsTest \ - org.ostelco.at.okhttp.GetSubscriptionStatusTest \ - org.ostelco.at.okhttp.SourceTest \ - org.ostelco.at.okhttp.PurchaseTest \ - org.ostelco.at.okhttp.ConsentTest \ - org.ostelco.at.okhttp.ProfileTest \ - org.ostelco.at.jersey.GetPseudonymsTest \ - org.ostelco.at.jersey.GetProductsTest \ - org.ostelco.at.jersey.GetSubscriptionStatusTest \ - org.ostelco.at.jersey.SourceTest \ - org.ostelco.at.jersey.PurchaseTest \ - org.ostelco.at.jersey.AnalyticsTest \ - org.ostelco.at.jersey.ConsentTest \ - org.ostelco.at.jersey.ProfileTest \ - org.ostelco.at.pgw.OcsTest +java -cp '/acceptance-tests.jar' org.junit.runner.JUnitCore org.ostelco.at.TestSuite \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/TestSuite.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/TestSuite.kt new file mode 100644 index 000000000..246a6f178 --- /dev/null +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/TestSuite.kt @@ -0,0 +1,59 @@ +package org.ostelco.at + +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.experimental.ParallelComputer +import org.junit.runner.JUnitCore +import org.ostelco.at.common.getLogger +import kotlin.test.assertEquals + +class TestSuite { + + private val logger by getLogger() + + @Test + fun `run all tests in parallel`() { + runBlocking { + + launch { + checkResult( + JUnitCore.runClasses( + ParallelComputer(true, true), + org.ostelco.at.okhttp.CustomerTest::class.java, + // org.ostelco.at.okhttp.SubscriptionsTest::class.java, + org.ostelco.at.okhttp.RegionsTest::class.java, + org.ostelco.at.okhttp.SingaporeKycTest::class.java, + org.ostelco.at.okhttp.GetProductsTest::class.java, + org.ostelco.at.okhttp.BundlesAndPurchasesTest::class.java, + org.ostelco.at.okhttp.SourceTest::class.java, + org.ostelco.at.okhttp.PurchaseTest::class.java, + org.ostelco.at.okhttp.GraphQlTests::class.java, + org.ostelco.at.jersey.CustomerTest::class.java, + // org.ostelco.at.jersey.SubscriptionsTest::class.java, + org.ostelco.at.jersey.RegionsTest::class.java, + org.ostelco.at.jersey.SingaporeKycTest::class.java, + org.ostelco.at.jersey.GetProductsTest::class.java, + org.ostelco.at.jersey.BundlesAndPurchasesTest::class.java, + org.ostelco.at.jersey.SourceTest::class.java, + org.ostelco.at.jersey.PurchaseTest::class.java, + org.ostelco.at.jersey.PlanTest::class.java, + org.ostelco.at.jersey.GraphQlTests::class.java, + org.ostelco.at.jersey.JumioKycTest::class.java)) + } + + launch { + checkResult(JUnitCore.runClasses(org.ostelco.at.pgw.OcsTest::class.java)) + } + } + } + + private fun checkResult(result: org.junit.runner.Result) { + + result.failures.forEach { + logger.error("{} {} {} {}", it.testHeader, it.message, it.description, it.trace) + } + + assertEquals(expected = 0, actual = result.failureCount) + } +} \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Auth.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Auth.kt index 5660b3baa..a3da888f1 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Auth.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Auth.kt @@ -6,10 +6,11 @@ import io.jsonwebtoken.SignatureAlgorithm private const val JWT_SIGNING_KEY = "jwt_secret" object Auth { - fun generateAccessToken(subject: String): String = Jwts.builder() + fun generateAccessToken(email: String): String = Jwts.builder() .setClaims(mapOf( + "https://ostelco/email" to email, "aud" to "http://ext-auth-provider:8080/userinfo", - "sub" to subject)) + "sub" to email)) .signWith(SignatureAlgorithm.HS512, JWT_SIGNING_KEY.toByteArray()) .compact() } \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Endpoint.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Endpoint.kt index 812c146d6..d18ea6bff 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Endpoint.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Endpoint.kt @@ -2,4 +2,8 @@ package org.ostelco.at.common // url will be http://prime:8080 while running via docker-compose, // and will be http://localhost:9090 when running in IDE connecting to prime in docker-compose -val url: String = "http://${System.getenv("PRIME_SOCKET") ?: "localhost:9090"}" \ No newline at end of file +val url: String = "http://${System.getenv("PRIME_SOCKET") ?: "localhost:9090"}" + +val ocsSocket = System.getenv("OCS_SOCKET") ?: "localhost:8082" + +val pubSubEmulatorHost = System.getenv("PUBSUB_EMULATOR_HOST") ?: "localhost:8085" diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt index 201c89053..fdd05ea67 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt @@ -1,7 +1,9 @@ package org.ostelco.at.common -import org.ostelco.prime.client.model.Price -import org.ostelco.prime.client.model.Product +import org.ostelco.prime.customer.model.Price +import org.ostelco.prime.customer.model.Product +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols fun expectedProducts(): List { return listOf( @@ -11,6 +13,11 @@ fun expectedProducts(): List { createProduct("5GB_399NOK", 39900)) } +private val dfs = DecimalFormatSymbols().apply { + groupingSeparator = '_' +} +private val df = DecimalFormat("#,###", dfs) + private fun createProduct(sku: String, amount: Int): Product { val product = Product() product.sku = sku @@ -20,8 +27,8 @@ private fun createProduct(sku: String, amount: Int): Product { // This is messy code val gbs: Long = "${sku[0]}".toLong() - product.properties = mapOf("noOfBytes" to "${gbs}_000_000_000") - product.presentation = mapOf("label" to "$gbs GB for ${amount/100}") + product.properties = mapOf("noOfBytes" to df.format(gbs * Math.pow(2.0, 30.0))) + product.presentation = mapOf("label" to "$gbs GB for ${amount / 100}") return product } \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt index ea0e5b232..06d39e8e5 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt @@ -60,33 +60,49 @@ object StripePayment { * verify that the correspondng 'setDefaultSource' API works as * intended. */ - fun getDefaultSourceForCustomer(customerId: String) : String { + fun getDefaultSourceForCustomer(stripeCustomerId: String) : String { // https://stripe.com/docs/api/java#create_source Stripe.apiKey = System.getenv("STRIPE_API_KEY") - val customer = Customer.retrieve(customerId) + val customer = Customer.retrieve(stripeCustomerId) return customer.defaultSource } /** * Obtains the Stripe 'customerId' directly from Stripe. */ - fun getCustomerIdForEmail(email: String) : String { - + fun getStripeCustomerId(customerId: String) : String { // https://stripe.com/docs/api/java#create_card_token Stripe.apiKey = System.getenv("STRIPE_API_KEY") val customers = Customer.list(emptyMap()).data - - return customers.filter { it.email.equals(email) }.first().id + return customers.first { it.id == customerId }.id } - fun deleteCustomer(email: String) { + fun deleteCustomer(customerId: String) { // https://stripe.com/docs/api/java#create_card_token Stripe.apiKey = System.getenv("STRIPE_API_KEY") val customers = Customer.list(emptyMap()).data - customers.filter { it.email == email } + customers.filter { it.id == customerId } .forEach { it.delete() } } -} \ No newline at end of file + + fun deleteAllCustomers() { + // https://stripe.com/docs/api/java#create_card_token + Stripe.apiKey = System.getenv("STRIPE_API_KEY") + while (true) { + val customers = Customer.list(emptyMap()).data + if (customers.isEmpty()) { + break + } + customers.forEach { + println(it.email) + it.delete() + } + } + } +} + +// use this just for cleanup +fun main() = StripePayment.deleteAllCustomers() \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/TestUser.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/TestUser.kt index 3a278d77b..b87d93575 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/TestUser.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/TestUser.kt @@ -2,24 +2,18 @@ package org.ostelco.at.common import org.apache.commons.lang3.RandomStringUtils import org.ostelco.at.jersey.post -import org.ostelco.prime.client.model.Profile +import org.ostelco.prime.customer.model.Customer +import org.ostelco.prime.customer.model.ScanInformation import java.util.* +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.MultivaluedHashMap -fun createProfile(name: String, email: String) { - - val createProfile = Profile() - .email(email) - .name(name) - .address("") - .city("") - .country("NO") - .postCode("") - .referralId("") - - post { - path = "/profile" - body = createProfile - subscriberId = email +fun createCustomer(name: String, email: String): Customer { + + return post { + path = "/customer" + queryParams = mapOf("nickname" to name, "contactEmail" to email) + this.email = email } } @@ -37,5 +31,25 @@ fun createSubscription(email: String): String { return msisdn } +fun enableRegion(email: String) { + + val scanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = MultivaluedHashMap(mapOf( + "jumioIdScanReference" to UUID.randomUUID().toString(), + "idScanStatus" to "SUCCESS", + "verificationStatus" to "APPROVED_VERIFIED", + "callbackDate" to "2018-12-07T09:19:07.036Z", + "idCountry" to "NOR", + "merchantIdScanReference" to scanInformation.scanId, + "identityVerification" to """{ "similarity":"MATCH", "validity":"TRUE"}""")) + } +} + private val random = Random() -fun randomInt(): Int = random.nextInt(999) \ No newline at end of file +fun randomInt(): Int = random.nextInt(99999) \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/HttpClientUtil.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/HttpClientUtil.kt index c8b04a7fc..4ed0cbd0a 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/HttpClientUtil.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/HttpClientUtil.kt @@ -18,7 +18,7 @@ class HttpRequest { var headerParams: Map> = emptyMap() var queryParams: Map = emptyMap() var body: Any? = null - var subscriberId = "foo@bar.com" + var email = "foo@bar.com" } /** @@ -26,7 +26,7 @@ class HttpRequest { */ inline fun get(execute: HttpRequest.() -> Unit): T { val request = HttpRequest().apply(execute) - val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.subscriberId).get() + val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.email).get() assertEquals(200, response.status) { response.readEntity(String::class.java) } return response.readEntity(object : GenericType() {}) } @@ -34,23 +34,22 @@ inline fun get(execute: HttpRequest.() -> Unit): T { /** * DSL function for POST operation */ -inline fun post(execute: HttpRequest.() -> Unit): T { +inline fun post(expectedResultCode: Int = 201, dataType: MediaType = MediaType.APPLICATION_JSON_TYPE, execute: HttpRequest.() -> Unit): T { val request = HttpRequest().apply(execute) - val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.subscriberId) - .post(Entity.entity(request.body ?: "", MediaType.APPLICATION_JSON_TYPE)) - assertEquals(201, response.status) { response.readEntity(String::class.java) } + val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.email) + .post(Entity.entity(request.body ?: "", dataType)) + assertEquals(expectedResultCode, response.status) { response.readEntity(String::class.java) } return response.readEntity(object : GenericType() {}) } - /** * DSL function for PUT operation */ -inline fun put(execute: HttpRequest.() -> Unit): T { +inline fun put(expectedResultCode: Int = 200, execute: HttpRequest.() -> Unit): T { val request = HttpRequest().apply(execute) - val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.subscriberId) + val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.email) .put(Entity.entity(request.body ?: "", MediaType.APPLICATION_JSON_TYPE)) - assertEquals(200, response.status) { response.readEntity(String::class.java) } + assertEquals(expectedResultCode, response.status) { response.readEntity(String::class.java) } return response.readEntity(object : GenericType() {}) } @@ -59,7 +58,7 @@ inline fun put(execute: HttpRequest.() -> Unit): T { */ inline fun delete(execute: HttpRequest.() -> Unit): T { val request = HttpRequest().apply(execute) - val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.subscriberId) + val response = HttpClient.send(request.path, request.queryParams, request.headerParams, request.email) .delete() assertEquals(200, response.status) { response.readEntity(String::class.java) } return response.readEntity(object : GenericType() {}) @@ -85,15 +84,16 @@ object HttpClient { path: String, queryParams: Map, headerParams: Map>, - url: String, subscriberId: String): JerseyInvocation.Builder { + url: String, + email: String): JerseyInvocation.Builder { var target = jerseyClient.target(url).path(path) queryParams.forEach { target = target.queryParam(it.key, it.value) } return target.request(MediaType.APPLICATION_JSON_TYPE) .headers(MultivaluedHashMap().apply { this.putAll(headerParams) }) - .header("Authorization", "Bearer ${generateAccessToken(subscriberId)}") + .header("Authorization", "Bearer ${generateAccessToken(email)}") } - fun send(path: String, queryParams: Map, headerParams: Map>, subscriberId: String): JerseyInvocation.Builder = - setup(path, queryParams, headerParams, url, subscriberId) + fun send(path: String, queryParams: Map, headerParams: Map>, email: String): JerseyInvocation.Builder = + setup(path, queryParams, headerParams, url, email) } diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt index c3a5e67fc..bfb2a324c 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt @@ -1,222 +1,289 @@ package org.ostelco.at.jersey +import org.junit.Ignore import org.junit.Test import org.ostelco.at.common.StripePayment -import org.ostelco.at.common.createProfile +import org.ostelco.at.common.createCustomer import org.ostelco.at.common.createSubscription +import org.ostelco.at.common.enableRegion import org.ostelco.at.common.expectedProducts import org.ostelco.at.common.getLogger import org.ostelco.at.common.randomInt -import org.ostelco.prime.client.model.ActivePseudonyms -import org.ostelco.prime.client.model.ApplicationToken -import org.ostelco.prime.client.model.Bundle -import org.ostelco.prime.client.model.Consent -import org.ostelco.prime.client.model.PaymentSource -import org.ostelco.prime.client.model.PaymentSourceList -import org.ostelco.prime.client.model.Person -import org.ostelco.prime.client.model.Price -import org.ostelco.prime.client.model.Product -import org.ostelco.prime.client.model.Profile -import org.ostelco.prime.client.model.PurchaseRecordList -import org.ostelco.prime.client.model.Subscription -import org.ostelco.prime.client.model.SubscriptionStatus +import org.ostelco.prime.customer.model.ApplicationToken +import org.ostelco.prime.customer.model.Bundle +import org.ostelco.prime.customer.model.BundleList +import org.ostelco.prime.customer.model.Customer +import org.ostelco.prime.customer.model.KycStatus +import org.ostelco.prime.customer.model.KycType +import org.ostelco.prime.customer.model.PaymentSource +import org.ostelco.prime.customer.model.PaymentSourceList +import org.ostelco.prime.customer.model.Person +import org.ostelco.prime.customer.model.Plan +import org.ostelco.prime.customer.model.Price +import org.ostelco.prime.customer.model.Product +import org.ostelco.prime.customer.model.ProductInfo +import org.ostelco.prime.customer.model.PurchaseRecord +import org.ostelco.prime.customer.model.PurchaseRecordList +import org.ostelco.prime.customer.model.Region +import org.ostelco.prime.customer.model.RegionDetails +import org.ostelco.prime.customer.model.RegionDetails.StatusEnum.APPROVED +import org.ostelco.prime.customer.model.RegionDetails.StatusEnum.PENDING +import org.ostelco.prime.customer.model.RegionDetailsList +import org.ostelco.prime.customer.model.ScanInformation +import org.ostelco.prime.customer.model.SimProfile +import org.ostelco.prime.customer.model.SimProfileList +import org.ostelco.prime.customer.model.Subscription +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.time.Instant import java.util.* +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.MultivaluedHashMap import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFailsWith -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -class ProfileTest { +class CustomerTest { @Test - fun `jersey test - GET and PUT profile`() { + fun `jersey test - GET and PUT customer`() { - val email = "profile-${randomInt()}@test.com" + val email = "customer-${randomInt()}@test.com" + val nickname = "Test Customer" + var customerId = "" + try { + val createdCustomer: Customer = post { + path = "/customer" + queryParams = mapOf( + "contactEmail" to email, + "nickname" to nickname) + this.email = email + } - val createProfile = Profile() - .email(email) - .name("Test Profile User") - .address("") - .city("") - .country("NO") - .postCode("") - .referralId("") + customerId = createdCustomer.id - val createdProfile: Profile = post { - path = "/profile" - body = createProfile - subscriberId = email - } + assertEquals(email, createdCustomer.contactEmail, "Incorrect 'contactEmail' in created customer") + assertEquals(nickname, createdCustomer.nickname, "Incorrect 'nickname' in created customer") - assertEquals(createProfile.email, createdProfile.email, "Incorrect 'email' in created profile") - assertEquals(createProfile.name, createdProfile.name, "Incorrect 'name' in created profile") - assertEquals(createProfile.email, createdProfile.referralId, "Incorrect 'referralId' in created profile") + val customer: Customer = get { + path = "/customer" + this.email = email + } - val profile: Profile = get { - path = "/profile" - subscriberId = email - } + assertEquals(createdCustomer.contactEmail, customer.contactEmail, "Incorrect 'contactEmail' in fetched customer") + assertEquals(createdCustomer.nickname, customer.nickname, "Incorrect 'nickname' in fetched customer") + assertEquals(createdCustomer.analyticsId, customer.analyticsId, "Incorrect 'analyticsId' in fetched customer") + assertEquals(createdCustomer.referralId, customer.referralId, "Incorrect 'referralId' in fetched customer") - assertEquals(email, profile.email, "Incorrect 'email' in fetched profile") - assertEquals(createProfile.name, profile.name, "Incorrect 'name' in fetched profile") - assertEquals(email, profile.referralId, "Incorrect 'referralId' in fetched profile") + val newName = "New name: Test Customer" - profile - .address("Some place") - .postCode("418") - .city("Udacity") - .country("Online") + val updatedCustomer: Customer = put { + path = "/customer" + queryParams = mapOf("nickname" to newName) + this.email = email + } - val updatedProfile: Profile = put { - path = "/profile" - body = profile - subscriberId = email + assertEquals(email, updatedCustomer.contactEmail, "Incorrect 'email' in response after updating customer") + assertEquals(newName, updatedCustomer.nickname, "Incorrect 'name' in response after updating customer") + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } + + @Test + fun `jersey test - POST application token`() { + + val email = "token-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer("Test Token User", email).id - assertEquals(email, updatedProfile.email, "Incorrect 'email' in response after updating profile") - assertEquals(createProfile.name, updatedProfile.name, "Incorrect 'name' in response after updating profile") - assertEquals("Some place", updatedProfile.address, "Incorrect 'address' in response after updating profile") - assertEquals("418", updatedProfile.postCode, "Incorrect 'postcode' in response after updating profile") - assertEquals("Udacity", updatedProfile.city, "Incorrect 'city' in response after updating profile") - assertEquals("Online", updatedProfile.country, "Incorrect 'country' in response after updating profile") + createSubscription(email) - updatedProfile - .address("") - .postCode("") - .city("") + val token = UUID.randomUUID().toString() + val applicationId = "testApplicationId" + val tokenType = "FCM" - val clearedProfile: Profile = put { - path = "/profile" - body = updatedProfile - subscriberId = email + val testToken = ApplicationToken() + .token(token) + .applicationID(applicationId) + .tokenType(tokenType) + + val reply: ApplicationToken = post { + path = "/applicationToken" + body = testToken + this.email = email + } + + assertEquals(token, reply.token, "Incorrect token in reply after posting new token") + assertEquals(applicationId, reply.applicationID, "Incorrect applicationId in reply after posting new token") + assertEquals(tokenType, reply.tokenType, "Incorrect tokenType in reply after posting new token") + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } +} + +class RegionsTest { - assertEquals(email, clearedProfile.email, "Incorrect 'email' in response after clearing profile") - assertEquals(createProfile.name, clearedProfile.name, "Incorrect 'name' in response after clearing profile") - assertEquals("", clearedProfile.address, "Incorrect 'address' in response after clearing profile") - assertEquals("", clearedProfile.postCode, "Incorrect 'postcode' in response after clearing profile") - assertEquals("", clearedProfile.city, "Incorrect 'city' in response after clearing profile") + @Test + fun `jersey test - GET regions - No regions`() { - updatedProfile.country("") + val email = "regions-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test No Region User", email = email).id - // A test in 'HttpClientUtil' checks for status code 200 while the - // expected status code is actually 400. - assertFailsWith(AssertionError::class, "Incorrectly accepts that 'country' is cleared/not set") { - put { - path = "/profile" - body = updatedProfile - subscriberId = email + val regionDetailsList: Collection = get { + path = "/regions" + this.email = email } + + assertTrue(regionDetailsList.isEmpty(), "RegionDetails list for new customer should be empty") + } finally { + StripePayment.deleteCustomer(customerId = customerId) } } @Test - fun `jersey test - POST application token`() { + fun `jersey test - GET regions - Single Region with no profiles`() { - val email = "token-${randomInt()}@test.com" - createProfile("Test Token User", email) + val email = "regions-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Single Region User", email = email).id + enableRegion(email = email) - createSubscription(email) + val regionDetailsList: Collection = get { + path = "/regions" + this.email = email + } - val token = UUID.randomUUID().toString() - val applicationId = "testApplicationId" - val tokenType = "FCM" + assertEquals(1, regionDetailsList.size, "Customer should have one region") - val testToken = ApplicationToken() - .token(token) - .applicationID(applicationId) - .tokenType(tokenType) + val regionDetails = RegionDetails() + .region(Region().id("no").name("Norway")) + .status(APPROVED) + .kycStatusMap(mapOf(KycType.JUMIO.name to KycStatus.APPROVED)) + .simProfiles(SimProfileList()) - val reply: ApplicationToken = post { - path = "/applicationtoken" - body = testToken - subscriberId = email + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } finally { + StripePayment.deleteCustomer(customerId = customerId) } - - assertEquals(token, reply.token, "Incorrect token in reply after posting new token") - assertEquals(applicationId, reply.applicationID, "Incorrect applicationId in reply after posting new token") - assertEquals(tokenType, reply.tokenType, "Incorrect tokenType in reply after posting new token") } -} - -class GetSubscriptions { + @Ignore @Test - fun `jersey test - GET subscriptions`() { + fun `jersey test - GET regions - Single Region with one profile`() { - val email = "subs-${randomInt()}@test.com" - createProfile(name = "Test Subscriptions User", email = email) - val msisdn = createSubscription(email) + val email = "regions-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Single Region User", email = email).id + enableRegion(email = email) - val subscriptions: Collection = get { - path = "/subscriptions" - subscriberId = email - } + post { + path = "/regions/no/simProfiles" + this.email = email + } - assertEquals(listOf(msisdn), subscriptions.map { it.msisdn }) - } -} + val regionDetailsList: Collection = get { + path = "/regions" + this.email = email + } -class GetSubscriptionStatusTest { + assertEquals(1, regionDetailsList.size, "Customer should have one region") - private val logger by getLogger() + val receivedRegion = regionDetailsList.first() - @Test - fun `jersey test - GET subscription status`() { + assertEquals(Region().id("no").name("Norway"), receivedRegion.region, "Region do not match") - val email = "balance-${randomInt()}@test.com" - createProfile(name = "Test Balance User", email = email) + assertEquals(APPROVED, receivedRegion.status, "Region status do not match") - val subscriptionStatus: SubscriptionStatus = get { - path = "/subscription/status" - subscriberId = email + assertEquals( + mapOf(KycType.JUMIO.name to KycStatus.APPROVED), + receivedRegion.kycStatusMap, + "Kyc status map do not match") + + assertEquals( + 1, + receivedRegion.simProfiles.size, + "Should have only one sim profile") + + assertNotNull(receivedRegion.simProfiles.single().iccId) + assertEquals("", receivedRegion.simProfiles.single().alias) + assertNotNull(receivedRegion.simProfiles.single().eSimActivationCode) + assertEquals(SimProfile.StatusEnum.AVAILABLE_FOR_DOWNLOAD, receivedRegion.simProfiles.single().status) + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } +} + +class SubscriptionsTest { - logger.info("Balance: ${subscriptionStatus.remaining}") + @Test + fun `jersey test - GET subscriptions`() { - val freeProduct = Product() - .sku("100MB_FREE_ON_JOINING") - .price(Price().apply { - this.amount = 0 - this.currency = "NOK" - }) - .properties(mapOf("noOfBytes" to "100_000_000")) - .presentation(emptyMap()) + val email = "subs-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Subscriptions User", email = email).id + enableRegion(email = email) + val msisdn = createSubscription(email) - val purchaseRecords = subscriptionStatus.purchaseRecords - purchaseRecords.sortBy { it.timestamp } + val subscriptions: Collection = get { + path = "regions/no/subscriptions" + this.email = email + } - assertEquals(listOf(freeProduct), purchaseRecords.map { it.product }, "Incorrect first 'Product' in purchase record") + assertEquals(listOf(msisdn), subscriptions.map { it.msisdn }) + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } -class GetPseudonymsTest { +class BundlesAndPurchasesTest { private val logger by getLogger() @Test - fun `jersey test - GET active pseudonyms`() { + fun `jersey test - GET bundles`() { - val email = "pseu-${randomInt()}@test.com" - createProfile(name = "Test Pseudonyms User", email = email) + val email = "balance-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Balance User", email = email).id + + val bundles: BundleList = get { + path = "/bundles" + this.email = email + } - createSubscription(email) + logger.info("Balance: ${bundles[0].balance}") - val activePseudonyms: ActivePseudonyms = get { - path = "/subscription/activePseudonyms" - subscriberId = email - } + val freeProduct = Product() + .sku("2GB_FREE_ON_JOINING") + .price(Price().amount(0).currency("")) + .properties(mapOf("noOfBytes" to "2_147_483_648")) + .presentation(emptyMap()) - logger.info("Current: ${activePseudonyms.current.pseudonym}") - logger.info("Next: ${activePseudonyms.next.pseudonym}") - assertNotNull(activePseudonyms.current.pseudonym, "Empty current pseudonym") - assertNotNull(activePseudonyms.next.pseudonym, "Empty next pseudonym") - assertEquals(activePseudonyms.current.end + 1, activePseudonyms.next.start, "The pseudonyms are not in order") + val purchaseRecords: PurchaseRecordList = get { + path = "/purchases" + this.email = email + } + purchaseRecords.sortBy { it.timestamp } + + assertEquals(listOf(freeProduct), purchaseRecords.map { it.product }, "Incorrect first 'Product' in purchase record") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } @@ -226,14 +293,20 @@ class GetProductsTest { fun `jersey test - GET products`() { val email = "products-${randomInt()}@test.com" - createProfile(name = "Test Products User", email = email) + var customerId = "" + try { + customerId = createCustomer(name = "Test Products User", email = email).id + enableRegion(email = email) - val products: List = get { - path = "/products" - subscriberId = email - } + val products: List = get { + path = "/products" + this.email = email + } - assertEquals(expectedProducts().toSet(), products.toSet(), "Incorrect 'Products' fetched") + assertEquals(expectedProducts().toSet(), products.toSet(), "Incorrect 'Products' fetched") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } @@ -243,16 +316,16 @@ class SourceTest { fun `jersey test - POST source create`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - - createProfile(name = "Test Payment Source", email = email) + customerId = createCustomer(name = "Test create Payment Source", email = email).id val tokenId = StripePayment.createPaymentTokenId() // Ties source with user profile both local and with Stripe post { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to tokenId) } @@ -260,14 +333,14 @@ class SourceTest { val sources: PaymentSourceList = get { path = "/paymentSources" - subscriberId = email + this.email = email } assert(sources.isNotEmpty()) { "Expected at least one payment source for profile $email" } val cardId = StripePayment.getCardIdForTokenId(tokenId) assertNotNull(sources.first { it.id == cardId }, "Expected card $cardId in list of payment sources for profile $email") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -275,9 +348,9 @@ class SourceTest { fun `jersey test - GET list sources`() { val email = "purchase-${randomInt()}@test.com" - + var customerId = "" try { - createProfile(name = "Test Payment Source", email = email) + customerId = createCustomer(name = "Test list Payment Source", email = email).id Thread.sleep(200) @@ -288,7 +361,7 @@ class SourceTest { val sources: PaymentSourceList = get { path = "/paymentSources" - subscriberId = email + this.email = email } val ids = createdIds.map { getCardIdForTokenFromStripe(it) } @@ -304,7 +377,7 @@ class SourceTest { } } } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -313,29 +386,31 @@ class SourceTest { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { + customerId = createCustomer(name = "Test List Payment Sources", email = email).id val sources: PaymentSourceList = get { path = "/paymentSources" - subscriberId = email + this.email = email } assert(sources.isEmpty()) { "Expected no payment source for profile $email" } - assertNotNull(StripePayment.getCustomerIdForEmail(email)) { "Customer Id should have been created" } + assertNotNull(StripePayment.getStripeCustomerId(customerId = customerId)) { "Customer Id should have been created" } } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } - @Test fun `jersey test - PUT source set default`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Payment Source", email = email) + customerId = createCustomer(name = "Test update Payment Source", email = email).id val tokenId = StripePayment.createPaymentTokenId() val cardId = StripePayment.getCardIdForTokenId(tokenId) @@ -343,7 +418,7 @@ class SourceTest { // Ties source with user profile both local and with Stripe post { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to tokenId) } @@ -354,28 +429,28 @@ class SourceTest { post { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to newTokenId) } // TODO: Update to fetch the Stripe customerId from 'admin' API when ready. - val customerId = StripePayment.getCustomerIdForEmail(email) + val stripeCustomerId = StripePayment.getStripeCustomerId(customerId = customerId) // Verify that original 'sourceId/card' is default. - assertEquals(cardId, StripePayment.getDefaultSourceForCustomer(customerId), - "Expected $cardId to be default source for $customerId") + assertEquals(cardId, StripePayment.getDefaultSourceForCustomer(stripeCustomerId), + "Expected $cardId to be default source for $stripeCustomerId") // Set new default card. put { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to newCardId) } - assertEquals(newCardId, StripePayment.getDefaultSourceForCustomer(customerId), - "Expected $newCardId to be default source for $customerId") + assertEquals(newCardId, StripePayment.getDefaultSourceForCustomer(stripeCustomerId), + "Expected $newCardId to be default source for $stripeCustomerId") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -383,63 +458,63 @@ class SourceTest { fun `jersey test - DELETE source`() { val email = "purchase-${randomInt()}@test.com" - + var customerId = "" try { - createProfile(name = "Test Payment Source", email = email) + customerId = createCustomer(name = "Test delete Payment Source", email = email).id Thread.sleep(200) val createdIds = listOf(getCardIdForTokenFromStripe(createTokenWithStripe(email)), createSourceWithStripe(email)) - val deletedIds = createdIds.map { it -> removeSourceWithStripe(email, it) } + val deletedIds = createdIds.map { removeSourceWithStripe(email, it) } assert(createdIds.containsAll(deletedIds.toSet())) { "Failed to delete one or more sources: ${createdIds.toSet() - deletedIds.toSet()}" } } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } // Helpers for source handling with Stripe. - private fun getCardIdForTokenFromStripe(id: String) : String { + private fun getCardIdForTokenFromStripe(id: String): String { if (id.startsWith("tok_")) { return StripePayment.getCardIdForTokenId(id) } return id } - private fun createTokenWithStripe(email: String) : String { + private fun createTokenWithStripe(email: String): String { val tokenId = StripePayment.createPaymentTokenId() post { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to tokenId) } return tokenId } - private fun createSourceWithStripe(email: String) : String { + private fun createSourceWithStripe(email: String): String { val sourceId = StripePayment.createPaymentSourceId() post { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to sourceId) } return sourceId } - private fun removeSourceWithStripe(email: String, sourceId: String) : String { + private fun removeSourceWithStripe(email: String, sourceId: String): String { val removedSource = delete { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to sourceId) } @@ -449,16 +524,20 @@ class SourceTest { class PurchaseTest { + private val logger by getLogger() + @Test fun `jersey test - POST products purchase`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Purchase User", email = email) + customerId = createCustomer(name = "Test Purchase User", email = email).id + enableRegion(email = email) val balanceBefore = get> { path = "/bundles" - subscriberId = email + this.email = email }.first().balance val productSku = "1GB_249NOK" @@ -466,7 +545,7 @@ class PurchaseTest { post { path = "/products/$productSku/purchase" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to sourceId) } @@ -474,14 +553,14 @@ class PurchaseTest { val balanceAfter = get> { path = "/bundles" - subscriberId = email + this.email = email }.first().balance - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + assertEquals(1_073_741_824, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") val purchaseRecords: PurchaseRecordList = get { path = "/purchases" - subscriberId = email + this.email = email } purchaseRecords.sortBy { it.timestamp } @@ -489,7 +568,7 @@ class PurchaseTest { assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -497,14 +576,16 @@ class PurchaseTest { fun `jersey test - POST products purchase using default source`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Purchase User with Default Payment Source", email = email) + customerId = createCustomer(name = "Test Purchase with Default Payment Source", email = email).id + enableRegion(email = email) val sourceId = StripePayment.createPaymentTokenId() val paymentSource: PaymentSource = post { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to sourceId) } @@ -512,28 +593,28 @@ class PurchaseTest { val balanceBefore = get> { path = "/bundles" - subscriberId = email + this.email = email }.first().balance val productSku = "1GB_249NOK" post { path = "/products/$productSku/purchase" - subscriberId = email + this.email = email } Thread.sleep(100) // wait for 100 ms for balance to be updated in db val balanceAfter = get> { path = "/bundles" - subscriberId = email + this.email = email }.first().balance - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + assertEquals(1_073_741_824, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") val purchaseRecords: PurchaseRecordList = get { path = "/purchases" - subscriberId = email + this.email = email } purchaseRecords.sortBy { it.timestamp } @@ -541,55 +622,122 @@ class PurchaseTest { assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } + @Test + fun `jersey test - Refund purchase using default source`() { + + val email = "purchase-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test refund Purchase User with Default Payment Source", email = email).id + enableRegion(email = email) + + val sourceId = StripePayment.createPaymentTokenId() + + val paymentSource: PaymentSource = post { + path = "/paymentSources" + this.email = email + queryParams = mapOf("sourceId" to sourceId) + } + + assertNotNull(paymentSource.id, message = "Failed to create payment source") + + val balanceBefore = get> { + path = "/bundles" + this.email = email + }.first().balance + + val productSku = "1GB_249NOK" + + post { + path = "/products/$productSku/purchase" + this.email = email + } + + Thread.sleep(100) // wait for 100 ms for balance to be updated in db + + val balanceAfter = get> { + path = "/bundles" + this.email = email + }.first().balance + + assertEquals(1_073_741_824, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + + val purchaseRecords: PurchaseRecordList = get { + path = "/purchases" + this.email = email + } + + purchaseRecords.sortBy { it.timestamp } + + assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } + assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + + val encodedEmail = URLEncoder.encode(email, "UTF-8") + val refundedProduct = put { + path = "/refund/$encodedEmail" + this.email = email + queryParams = mapOf( + "purchaseRecordId" to purchaseRecords.last().id, + "reason" to "requested_by_customer") + } + logger.info("Refunded product: $refundedProduct with purchase id:${purchaseRecords.last().id}") + assertEquals(productSku, refundedProduct.id, "Refund returned a different product") + + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } @Test fun `jersey test - POST products purchase add source then pay with it`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Purchase User with Default Payment Source", email = email) + customerId = createCustomer(name = "Test Purchase with adding Payment Source", email = email).id + enableRegion(email = email) val sourceId = StripePayment.createPaymentTokenId() val paymentSource: PaymentSource = post { path = "/paymentSources" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to sourceId) } assertNotNull(paymentSource.id, message = "Failed to create payment source") - val subscriptionStatusBefore: SubscriptionStatus = get { - path = "/subscription/status" - subscriberId = email + val bundlesBefore: BundleList = get { + path = "/bundles" + this.email = email } - val balanceBefore = subscriptionStatusBefore.remaining + val balanceBefore = bundlesBefore[0].balance val productSku = "1GB_249NOK" post { path = "/products/$productSku/purchase" - subscriberId = email + this.email = email queryParams = mapOf("sourceId" to paymentSource.id) } Thread.sleep(100) // wait for 100 ms for balance to be updated in db - val subscriptionStatusAfter: SubscriptionStatus = get { - path = "/subscription/status" - subscriberId = email + val bundlesAfter: BundleList = get { + path = "/bundles" + this.email = email } - val balanceAfter = subscriptionStatusAfter.remaining + val balanceAfter = bundlesAfter[0].balance - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + assertEquals(1_073_741_824, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") val purchaseRecords: PurchaseRecordList = get { path = "/purchases" - subscriberId = email + this.email = email } purchaseRecords.sortBy { it.timestamp } @@ -597,216 +745,1073 @@ class PurchaseTest { assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } +} + +class JumioKycTest { + + private val imgUrl = "https://www.gstatic.com/webp/gallery3/1.png" + private val imgUrl2 = "https://www.gstatic.com/webp/gallery3/2.png" @Test - fun `jersey test - POST products purchase without payment`() { + fun `jersey test - GET new-ekyc-scanId - generate new scanId for eKYC`() { - val email = "purchase-legacy-${randomInt()}@test.com" - createProfile(name = "Test Legacy Purchase User", email = email) + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id + + val scanInfo: ScanInformation = post { + path = "regions/no/kyc/jumio/scans" + this.email = email + } + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") - val balanceBefore = get> { - path = "/bundles" - subscriberId = email - }.first().balance + val regionDetails = get { + path = "/regions" + this.email = email + }.single() - val productSku = "1GB_249NOK" + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(PENDING, regionDetails.status, message = "Wrong State") - post { - path = "/products/$productSku" - subscriberId = email + assertEquals( + expected = mapOf( + KycType.JUMIO.name to KycStatus.PENDING), + actual = regionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } - Thread.sleep(100) // wait for 100 ms for balance to be updated in db + @Test + fun `jersey test - ekyc callback - test the call back processing`() { - val balanceAfter = get> { - path = "/bundles" - subscriberId = email - }.first().balance + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } - val purchaseRecords: PurchaseRecordList = get { - path = "/purchases" - subscriberId = email + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("ERROR") + dataMap["verificationStatus"] = listOf("DENIED_FRAUD") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(PENDING, regionDetails.status, message = "Wrong State") + + assertEquals( + expected = mapOf( + KycType.JUMIO.name to KycStatus.REJECTED), + actual = regionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } + + @Test + fun `jersey test - ekyc callback - process success`() { + + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id + + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("SUCCESS") + dataMap["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + val identityVerification = """{ "similarity":"MATCH", "validity":"TRUE"}""" + dataMap["identityVerification"] = listOf(identityVerification) + dataMap["livenessImages"] = listOf(imgUrl, imgUrl2) + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() - purchaseRecords.sortBy { it.timestamp } + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(APPROVED, regionDetails.status, message = "Wrong State") - assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } - assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + assertEquals( + expected = mapOf( + KycType.JUMIO.name to KycStatus.APPROVED), + actual = regionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } -} -class AnalyticsTest { + @Test + fun `jersey test - ekyc callback - process failure of face id`() { + + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id + + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("SUCCESS") + dataMap["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + val identityVerification = """{ "similarity":"MATCH", "validity":"FALSE", "reason": "ENTIRE_ID_USED_AS_SELFIE" }""" + dataMap["identityVerification"] = listOf(identityVerification) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(PENDING, regionDetails.status, message = "Wrong State") + + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.REJECTED), + actual = regionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } @Test - fun testReportEvent() { + fun `jersey test - ekyc callback - process incomplete form data`() { + + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id - val email = "analytics-${randomInt()}@test.com" - createProfile(name = "Test Analytics User", email = email) + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } - post { - path = "/analytics" - body = "event" - subscriberId = email + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("SUCCESS") + dataMap["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + //dataMap.put("merchantIdScanReference", listOf(scanInfo.scanId)) + + post(expectedResultCode = 400, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(PENDING, regionDetails.status, message = "Wrong State") + + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.PENDING), + actual = regionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) } } -} -class ConsentTest { + @Test + fun `jersey test - ekyc callback - incomplete face id verification data`() { + + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id + + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("SUCCESS") + dataMap["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } - private val consentId = "privacy" + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(PENDING, regionDetails.status, message = "Wrong State") + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.REJECTED), + actual = regionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } @Test - fun `jersey test - GET and PUT consent`() { + fun `jersey test - ekyc callback - reject & approve`() { + + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id + + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("ERROR") + dataMap["verificationStatus"] = listOf("DENIED_FRAUD") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() - val email = "consent-${randomInt()}@test.com" - createProfile(name = "Test Consent User", email = email) + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(PENDING, regionDetails.status, message = "Wrong State") + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.REJECTED), + actual = regionDetails.kycStatusMap) - val defaultConsent: List = get { - path = "/consents" - subscriberId = email + val newScanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(newScanInfo.scanId, message = "Failed to get new scanId") + + + val dataMap2 = MultivaluedHashMap() + dataMap2["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap2["idScanStatus"] = listOf("SUCCESS") + dataMap2["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap2["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap2["idType"] = listOf("LICENSE") + dataMap2["idCountry"] = listOf("NOR") + dataMap2["idFirstName"] = listOf("Test User") + dataMap2["idLastName"] = listOf("Test Family") + dataMap2["idDob"] = listOf("1990-12-09") + dataMap2["merchantIdScanReference"] = listOf(newScanInfo.scanId) + dataMap2["idScanImage"] = listOf(imgUrl) + dataMap2["idScanImageBackside"] = listOf(imgUrl2) + dataMap2["livenessImages"] = listOf(imgUrl, imgUrl2) + val identityVerification = """{ "similarity":"MATCH", "validity":"TRUE"}""" + dataMap2["identityVerification"] = listOf(identityVerification) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap2 + } + + val newRegionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), newRegionDetails.region) + assertEquals(APPROVED, newRegionDetails.status, message = "Wrong State") + + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.APPROVED), + actual = newRegionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } + + @Test + fun `jersey test - ekyc verify scan information`() { + + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id - assertEquals(1, defaultConsent.size, "Incorrect number of consents fetched") - assertEquals(consentId, defaultConsent[0].consentId, "Incorrect 'consent id' in fetched consent") + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("SUCCESS") + dataMap["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + dataMap["idScanImage"] = listOf(imgUrl) + dataMap["idScanImageBackside"] = listOf(imgUrl2) + dataMap["livenessImages"] = listOf(imgUrl, imgUrl2) + val identityVerification = """{ "similarity":"MATCH", "validity":"TRUE"}""" + dataMap["identityVerification"] = listOf(identityVerification) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + val scanInformation: ScanInformation = get { + path = "/regions/no/kyc/jumio/scans/${scanInfo.scanId}" + this.email = email + } + assertEquals("APPROVED", scanInformation.status, message = "Wrong status") + + val encodedEmail = URLEncoder.encode(email, "UTF-8") + val scanInformationList = get> { + path = "/profiles/$encodedEmail/scans" + this.email = email + } + assertEquals(1, scanInformationList.size, message = "More scans than expected") + assertEquals("APPROVED", scanInformationList.elementAt(0).status, message = "Wrong status") - val acceptedConsent: Consent = put { - path = "/consents/$consentId" - subscriberId = email + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } + + @Test + fun `jersey test - ekyc verify 2 scans`() { - assertEquals(consentId, acceptedConsent.consentId, "Incorrect 'consent id' in response after accepting consent") - assertTrue(acceptedConsent.isAccepted - ?: false, "Accepted consent not reflected in response after accepting consent") + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id - val rejectedConsent: Consent = put { - path = "/consents/$consentId" - queryParams = mapOf("accepted" to "false") - subscriberId = email + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("ERROR") + dataMap["verificationStatus"] = listOf("DENIED_FRAUD") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + val newRegionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), newRegionDetails.region) + assertEquals(PENDING, newRegionDetails.status, message = "Wrong State") + + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.REJECTED), + actual = newRegionDetails.kycStatusMap) + + val newScanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(newScanInfo.scanId, message = "Failed to get new scanId") + + val dataMap2 = MultivaluedHashMap() + dataMap2["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap2["idScanStatus"] = listOf("SUCCESS") + dataMap2["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap2["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap2["idType"] = listOf("LICENSE") + dataMap2["idCountry"] = listOf("NOR") + dataMap2["idFirstName"] = listOf("Test User") + dataMap2["idLastName"] = listOf("Test Family") + dataMap2["idDob"] = listOf("1990-12-09") + dataMap2["merchantIdScanReference"] = listOf(newScanInfo.scanId) + dataMap2["idScanImage"] = listOf(imgUrl) + dataMap2["idScanImageBackside"] = listOf(imgUrl2) + // JUMIO POST data for livenesss images are interpreted like this by HTTP client in prime. + val stringList = "[ \"$imgUrl\", \"$imgUrl2\" ]" + dataMap2["livenessImages"] = listOf(stringList) + val identityVerification = """{ "similarity":"MATCH", "validity":"TRUE"}""" + dataMap2["identityVerification"] = listOf(identityVerification) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap2 + } + + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(APPROVED, regionDetails.status, message = "Wrong State") + + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.APPROVED), + actual = regionDetails.kycStatusMap) + + val encodedEmail = URLEncoder.encode(email, "UTF-8") + val scanInformationList = get> { + path = "/profiles/$encodedEmail/scans" + this.email = email + } + assertEquals(2, scanInformationList.size, message = "More scans than expected") + var verifiedItemIndex = 0 + if (newScanInfo.scanId == scanInformationList.elementAt(1).scanId) { + verifiedItemIndex = 1 + } + assertEquals("APPROVED", scanInformationList.elementAt(verifiedItemIndex).status, message = "Wrong status") + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } + + @Test + fun `jersey test - ekyc rejected with detailed reject reason`() { + + val email = "ekyc-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test User for eKYC", email = email).id + + val scanInfo: ScanInformation = post { + path = "/regions/no/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + val rejectReason = """{ "rejectReasonCode":"100", "rejectReasonDescription":"MANIPULATED_DOCUMENT", "rejectReasonDetails": [{ "detailsCode": "1001", "detailsDescription": "PHOTO" },{ "detailsCode": "1004", "detailsDescription": "DOB" }]}""" + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("ERROR") + dataMap["verificationStatus"] = listOf("DENIED_FRAUD") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + dataMap["rejectReason"] = listOf(rejectReason) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } - assertEquals(consentId, rejectedConsent.consentId, "Incorrect 'consent id' in response after rejecting consent") - assertFalse(rejectedConsent.isAccepted - ?: true, "Accepted consent not reflected in response after rejecting consent") + val regionDetails = get> { + path = "/regions" + this.email = email + }.single() + + assertEquals(Region().id("no").name("Norway"), regionDetails.region) + assertEquals(PENDING, regionDetails.status, message = "Wrong State") + + assertEquals( + expected = mapOf(KycType.JUMIO.name to KycStatus.REJECTED), + actual = regionDetails.kycStatusMap) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } +} + +class SingaporeKycTest { + + @Test + fun `jersey test - GET myinfo`() { + + val email = "myinfo-${randomInt()}@test.com" + var customerId = "" + try { + + customerId = createCustomer(name = "Test MyInfo Customer", email = email).id + + run { + val regionDetailsList = get { + path = "/regions" + this.email = email + } + + assertTrue(regionDetailsList.isEmpty(), "regionDetailsList should be empty") + } + + val personData: String = get { + path = "/regions/sg/kyc/myInfo/authCode" + this.email = email + } + + val expectedPersonData = """{"name":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"TANXIAOHUI"},"sex":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"F"},"nationality":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"SG"},"dob":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"1970-05-17"},"email":{"lastupdated":"2018-08-23","source":"4","classification":"C","value":"myinfotesting@gmail.com"},"mobileno":{"lastupdated":"2018-08-23","code":"65","source":"4","classification":"C","prefix":"+","nbr":"97399245"},"regadd":{"country":"SG","unit":"128","street":"BEDOKNORTHAVENUE4","lastupdated":"2018-03-20","block":"102","postal":"460102","source":"1","classification":"C","floor":"09","building":"PEARLGARDEN"},"uinfin":"S9812381D"}""" + assertEquals(expectedPersonData, personData, "MyInfo PersonData do not match") + + run { + val regionDetailsList = get { + path = "/regions" + this.email = email + } + + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") + + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(APPROVED) + .kycStatusMap(mutableMapOf( + KycType.JUMIO.name to KycStatus.PENDING, + KycType.MY_INFO.name to KycStatus.APPROVED, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.PENDING, + KycType.NRIC_FIN.name to KycStatus.PENDING)) + .simProfiles(SimProfileList()) + + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } + + @Test + fun `jersey test - NRIC, Jumio and address`() { + + val email = "myinfo-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test MyInfo Customer", email = email).id + + run { + val regionDetailsList = get { + path = "/regions" + this.email = email + } + + assertTrue(regionDetailsList.isEmpty(), "regionDetailsList should be empty") + } + + get { + path = "/regions/sg/kyc/dave/S7808018C" + this.email = email + } + + run { + val regionDetailsList = get { + path = "/regions" + this.email = email + } + + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") + + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(PENDING) + .kycStatusMap(mutableMapOf( + KycType.MY_INFO.name to KycStatus.PENDING, + KycType.NRIC_FIN.name to KycStatus.APPROVED, + KycType.JUMIO.name to KycStatus.PENDING, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.PENDING)) + .simProfiles(SimProfileList()) + + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } + + val scanInfo: ScanInformation = post { + path = "/regions/sg/kyc/jumio/scans" + this.email = email + } + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("SUCCESS") + dataMap["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + val identityVerification = """{ "similarity":"MATCH", "validity":"TRUE"}""" + dataMap["identityVerification"] = listOf(identityVerification) + val imgUrl = "https://www.gstatic.com/webp/gallery3/1.png" + val imgUrl2 = "https://www.gstatic.com/webp/gallery3/2.png" + dataMap["livenessImages"] = listOf(imgUrl, imgUrl2) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } + + run { + val regionDetailsList = get> { + path = "/regions" + this.email = email + } + + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") + + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(PENDING) + .kycStatusMap(mutableMapOf( + KycType.MY_INFO.name to KycStatus.PENDING, + KycType.NRIC_FIN.name to KycStatus.APPROVED, + KycType.JUMIO.name to KycStatus.APPROVED, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.PENDING)) + .simProfiles(SimProfileList()) + + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } + + put(expectedResultCode = 204) { + path = "/regions/sg/kyc/profile" + this.email = email + queryParams = mapOf("address" to "Singapore", "phoneNumber" to "1234") + } + + run { + val regionDetailsList = get { + path = "/regions" + this.email = email + } + + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") + + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(APPROVED) + .kycStatusMap(mutableMapOf( + KycType.JUMIO.name to KycStatus.APPROVED, + KycType.MY_INFO.name to KycStatus.PENDING, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.APPROVED, + KycType.NRIC_FIN.name to KycStatus.APPROVED)) + .simProfiles(SimProfileList()) + + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } class ReferralTest { @Test - fun `jersey test - POST profile with invalid referred by`() { + fun `jersey test - POST customer with invalid referred by`() { val email = "referred_by_invalid-${randomInt()}@test.com" val invalid = "invalid_referrer@test.com" - val profile = Profile() - .email(email) - .name("Test Referral Second User") - .address("") - .city("") - .country("") - .postCode("") + val customer = Customer() + .contactEmail(email) + .nickname("Test Referral Second User") .referralId("") val failedToCreate = assertFails { - post { - path = "/profile" - body = profile - subscriberId = email + post { + path = "/customer" + body = customer + this.email = email queryParams = mapOf("referred_by" to invalid) } } assertEquals(""" -{"description":"Incomplete profile description. Subscriber - $invalid not found."} expected:<201> but was:<403> +{"description":"Incomplete customer description. Subscriber - $invalid not found."} expected:<201> but was:<403> """.trimIndent(), failedToCreate.message) val failedToGet = assertFails { - get { - path = "/profile" - subscriberId = email + get { + path = "/customer" + this.email = email } } assertEquals(""" -{"description":"Incomplete profile description. Subscriber - $email not found."} expected:<200> but was:<404> +{"description":"Incomplete customer description. Subscriber - $email not found."} expected:<200> but was:<404> """.trimIndent(), failedToGet.message) } @Test - fun `jersey test - POST profile`() { + fun `jersey test - POST customer`() { val firstEmail = "referral_first-${randomInt()}@test.com" - createProfile(name = "Test Referral First User", email = firstEmail) + var customerId = "" + try { + customerId = createCustomer(name = "Test Referral First User", email = firstEmail).id - val secondEmail = "referral_second-${randomInt()}@test.com" + val secondEmail = "referral_second-${randomInt()}@test.com" - val profile = Profile() - .email(secondEmail) - .name("Test Referral Second User") - .address("") - .city("") - .country("") - .postCode("") - .referralId("") + val customer = Customer() + .contactEmail(secondEmail) + .nickname("Test Referral Second User") + .referralId("") + + post { + path = "/customer" + body = customer + email = secondEmail + queryParams = mapOf("referred_by" to firstEmail) + } + + // for first + val referralsForFirst: List = get { + path = "/referred" + email = firstEmail + } + assertEquals(listOf("Test Referral Second User"), referralsForFirst.map { it.name }) - post { - path = "/profile" - body = profile - subscriberId = secondEmail - queryParams = mapOf("referred_by" to firstEmail) + val referredByForFirst: Person = get { + path = "/referred/by" + email = firstEmail + } + assertNull(referredByForFirst.name) + + // No need to test SubscriptionStatus for first, since it is already tested in GetSubscriptionStatusTest. + + // for referred_by_foo + val referralsForSecond: List = get { + path = "/referred" + email = secondEmail + } + assertEquals(emptyList(), referralsForSecond.map { it.name }) + + val referredByForSecond: Person = get { + path = "/referred/by" + email = secondEmail + } + assertEquals("Test Referral First User", referredByForSecond.name) + + val secondSubscriberBundles: BundleList = get { + path = "/bundles" + email = secondEmail + } + + assertEquals(1_000_000_000, secondSubscriberBundles[0].balance) + + val secondSubscriberPurchases: PurchaseRecordList = get { + path = "/purchases" + email = secondEmail + } + + val freeProductForReferred = Product() + .sku("1GB_FREE_ON_REFERRED") + .price(Price().amount(0).currency("NOK")) + .properties(mapOf("noOfBytes" to "1_000_000_000")) + .presentation(emptyMap()) + + assertEquals(listOf(freeProductForReferred), secondSubscriberPurchases.map { it.product }) + } finally { + StripePayment.deleteCustomer(customerId = customerId) } + } +} + +class PlanTest { - // for first - val referralsForFirst: List = get { - path = "/referred" - subscriberId = firstEmail + @Test + fun `jersey test - POST plan`() { + + val price = Price() + .amount(100) + .currency("nok") + val plan = Plan() + .name("PLAN_1_NOK_PER_DAY-${randomInt()}") + .price(price) + .interval(Plan.IntervalEnum.DAY) + .intervalCount(1) + .properties(emptyMap()) + .presentation(emptyMap()) + + post { + path = "/plans" + body = plan } - assertEquals(listOf("Test Referral Second User"), referralsForFirst.map { it.name }) - val referredByForFirst: Person = get { - path = "/referred/by" - subscriberId = firstEmail + val stored: Plan = get { + path = "/plans/${plan.name}" } - assertNull(referredByForFirst.name) - // No need to test SubscriptionStatus for first, since it is already tested in GetSubscriptionStatusTest. + assertEquals(plan.name, stored.name) + assertEquals(plan.price, stored.price) + assertEquals(plan.interval, stored.interval) + assertEquals(plan.intervalCount, stored.intervalCount) - // for referred_by_foo - val referralsForSecond: List = get { - path = "/referred" - subscriberId = secondEmail + val deletedPLan: Plan = delete { + path = "/plans/${plan.name}" } - assertEquals(emptyList(), referralsForSecond.map { it.name }) - val referredByForSecond: Person = get { - path = "/referred/by" - subscriberId = secondEmail - } - assertEquals("Test Referral First User", referredByForSecond.name) + assertEquals(plan.name, deletedPLan.name) + assertEquals(plan.price, deletedPLan.price) + assertEquals(plan.interval, deletedPLan.interval) + assertEquals(plan.intervalCount, deletedPLan.intervalCount) - val secondSubscriptionStatus: SubscriptionStatus = get { - path = "/subscription/status" - subscriberId = secondEmail + assertFailsWith(AssertionError::class, "Plan ${plan.name} not removed") { + get { + path = "/plans/${plan.name}" + } } + } + + @Test + fun `jersey test - POST profiles plans`() { - assertEquals(1_000_000_000, secondSubscriptionStatus.remaining) + val email = "purchase-${randomInt()}@test.com" + + val price = Price() + .amount(100) + .currency("nok") + val plan = Plan() + .name("plan-${randomInt()}") + .price(price) + .interval(Plan.IntervalEnum.DAY) + .intervalCount(1) + .properties(emptyMap()) + .presentation(emptyMap()) + + var customerId = "" - val freeProductForReferred = Product() - .sku("1GB_FREE_ON_REFERRED") - .price(Price().apply { - this.amount = 0 - this.currency = "NOK" - }) - .properties(mapOf("noOfBytes" to "1_000_000_000")) - .presentation(emptyMap()) + try { + // Create subscriber with payment source. + + customerId = createCustomer(name = "Test create Profile Plans", email = email).id - assertEquals(listOf(freeProductForReferred), secondSubscriptionStatus.purchaseRecords.map { it.product }) + val sourceId = StripePayment.createPaymentTokenId() + + val paymentSource: PaymentSource = post { + path = "/paymentSources" + this.email = email + queryParams = mapOf("sourceId" to sourceId) + } + + assertNotNull(paymentSource.id, message = "Failed to create payment source") + + // Create a plan. + + post { + path = "/plans" + body = plan + } + + val stored: Plan = get { + path = "/plans/${plan.name}" + } + + assertEquals(plan.name, stored.name) + + // Now create and verify the subscription. + + post { + path = "/profiles/$email/plans/${plan.name}" + } + + val plans: List = get { + path = "/profiles/$email/plans" + } + + assert(plans.isNotEmpty()) + assert(plans.lastIndex == 0) + assertEquals(plan.name, plans[0].name) + assertEquals(plan.price, plans[0].price) + assertEquals(plan.interval, plans[0].interval) + assertEquals(plan.intervalCount, plans[0].intervalCount) + + delete { + path = "/profiles/$email/plans/${plan.name}" + } + + // Cleanup - remove plan. + val deletedPLan: Plan = delete { + path = "/plans/${plan.name}" + } + assertEquals(plan.name, deletedPLan.name) + + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } + +class GraphQlTests { + + data class Context( + val customer: Customer? = null, + val bundles: Collection? = null, + val subscriptions: Collection? = null, + val products: Collection? = null, + val purchases: Collection? = null) + + data class Data(var context: Context? = null) + + data class GraphQlResponse(var data: Data? = null, var errors: List? = null) + + @Test + fun `jersey test - POST graphql`() { + + val email = "graphql-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer("Test GraphQL Endpoint", email).id + + val msisdn = createSubscription(email) + + val context = post(expectedResultCode = 200) { + path = "/graphql" + this.email = email + body = mapOf("query" to """{ context { customer { nickname contactEmail } subscriptions { msisdn } } }""") + }.data?.context + + assertEquals(expected = email, actual = context?.customer?.contactEmail) + assertEquals(expected = msisdn, actual = context?.subscriptions?.first()?.msisdn) + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } + + @Test + fun `jersey test - GET graphql`() { + + val email = "graphql-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer("Test GraphQL Endpoint", email).id + + val msisdn = createSubscription(email) + + val context = get { + path = "/graphql" + this.email = email + queryParams = mapOf("query" to URLEncoder.encode("""{context{customer{nickname,contactEmail}subscriptions{msisdn}}}""", StandardCharsets.UTF_8.name())) + }.data?.context + + assertEquals(expected = email, actual = context?.customer?.contactEmail) + assertEquals(expected = msisdn, actual = context?.subscriptions?.first()?.msisdn) + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } +} \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/loadtest/OcsLoadTest.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/loadtest/OcsLoadTest.kt new file mode 100644 index 000000000..3e6e17584 --- /dev/null +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/loadtest/OcsLoadTest.kt @@ -0,0 +1,328 @@ +package org.ostelco.at.loadtest + +import com.google.api.core.ApiFutureCallback +import com.google.api.core.ApiFutures +import com.google.api.gax.core.NoCredentialsProvider +import com.google.api.gax.grpc.GrpcTransportChannel +import com.google.api.gax.rpc.ApiException +import com.google.api.gax.rpc.FixedTransportChannelProvider +import com.google.api.gax.rpc.TransportChannelProvider +import com.google.cloud.pubsub.v1.AckReplyConsumer +import com.google.cloud.pubsub.v1.MessageReceiver +import com.google.cloud.pubsub.v1.Publisher +import com.google.cloud.pubsub.v1.Subscriber +import com.google.protobuf.ByteString +import com.google.pubsub.v1.ProjectSubscriptionName +import com.google.pubsub.v1.ProjectTopicName +import com.google.pubsub.v1.PubsubMessage +import io.grpc.ManagedChannelBuilder +import io.grpc.stub.StreamObserver +import org.junit.Test +import org.ostelco.at.common.createCustomer +import org.ostelco.at.common.createSubscription +import org.ostelco.at.common.getLogger +import org.ostelco.at.common.ocsSocket +import org.ostelco.at.common.pubSubEmulatorHost +import org.ostelco.at.jersey.get +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.ocs.api.CreditControlRequestType.UPDATE_REQUEST +import org.ostelco.ocs.api.MultipleServiceCreditControl +import org.ostelco.ocs.api.OcsServiceGrpc +import org.ostelco.ocs.api.ServiceUnit +import org.ostelco.prime.customer.model.BundleList +import java.time.Instant +import java.util.* +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlin.streams.toList +import kotlin.test.assertEquals +import kotlin.test.fail + +class OcsLoadTest { + + private val logger by getLogger() + + /** + * Start docker compose locally before running this test. + * + * 'docker-compose -f docker-compose.ocs.yaml up --build' + * + * Set env variable GCP_PROJECT_ID. + * + */ + @Test + fun `load test OCS using PubSub`() { + + val users = createTestUsers() + + val channel = ManagedChannelBuilder.forTarget(pubSubEmulatorHost).usePlaintext().build() + // Create a publisher instance with default settings bound to the topic + val pubSubChannelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)) + + val projectId = System.getenv("GCP_PROJECT_ID") + + val ccrPublisher = setupPublisherToTopic( + channelProvider = pubSubChannelProvider, + projectId = projectId, + topicId = "ocs-ccr") + + + // count down latch to wait for all responses to return + val cdl = CountDownLatch(COUNT) + + setupPubSubSubscriber( + channelProvider = pubSubChannelProvider, + projectId = projectId, + subscriptionId = "ocsgw-cca-sub") { _, consumer -> + + cdl.countDown() + consumer.ack() + } + + benchmark { + + val executor = Executors.newSingleThreadExecutor() + // Send the same request COUNT times + (0 until COUNT) + .toList() + .parallelStream() + .map { i -> + + val requestId = UUID.randomUUID().toString() + val request = CreditControlRequestInfo.newBuilder() + .setRequestId(requestId) + .setType(UPDATE_REQUEST) + .setMsisdn(users[i % USER_COUNT].msisdn) + .addMscc(0, MultipleServiceCreditControl.newBuilder() + .setRequested(ServiceUnit.newBuilder().setTotalOctets(REQUESTED_BYTES)) + .setUsed(ServiceUnit.newBuilder().setTotalOctets(USED_BYTES))) + .setTopicId("ocs-cca") + .build() + + publish(messageId = requestId, + publisher = ccrPublisher, + byteString = request.toByteString(), + executor = executor) + } + .toList() + + // Wait for all the responses to be returned + println("Waiting for all responses to be returned") + cdl.await() + } + + //for (i in 0 until USER_COUNT) { + assertUserBalance( + email = users[0].email, + expectedBalance = 100_000_000 - COUNT * USED_BYTES / USER_COUNT + USED_BYTES - REQUESTED_BYTES) + //} + } + + private fun publish( + messageId: String, + byteString: ByteString, + publisher: Publisher, + executor: Executor) { + + val base64String = Base64.getEncoder().encodeToString(byteString.toByteArray()) + val pubsubMessage = PubsubMessage.newBuilder() + .setMessageId(messageId) + .setData(ByteString.copyFromUtf8(base64String)) + .build() + + val future = publisher.publish(pubsubMessage) + + ApiFutures.addCallback(future, object : ApiFutureCallback { + + override fun onFailure(throwable: Throwable) { + if (throwable is ApiException) { + // details on the API exception + logger.error("Status code: {}", throwable.statusCode.code) + logger.error("Retrying: {}", throwable.isRetryable) + } + logger.error("Error sending CCR Request to PubSub") + } + + override fun onSuccess(messageId: String) { + // Once published, returns server-assigned message ids (unique within the topic) + logger.debug("Submitted message with request-id: {} successfully", messageId) + } + }, executor) + } + + private fun setupPubSubSubscriber( + channelProvider: TransportChannelProvider, + projectId: String, + subscriptionId: String, + handler: (ByteString, AckReplyConsumer) -> Unit) { + + // init subscriber + logger.info("Setting up Subscriber for subscription: {}", subscriptionId) + val subscriptionName = ProjectSubscriptionName.of(projectId, subscriptionId) + + val receiver = MessageReceiver { message, consumer -> + val base64String = message.data.toStringUtf8() + logger.debug("[<<] base64String: {}", base64String) + handler(ByteString.copyFrom(Base64.getDecoder().decode(base64String)), consumer) + } + + val subscriber: Subscriber? + try { + // Create a subscriber for "my-subscription-id" bound to the message receiver + subscriber = Subscriber.newBuilder(subscriptionName, receiver) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider()) + .build() + subscriber?.startAsync()?.awaitRunning() + } finally { + // TODO vihang: Stop this in Managed.stop() + // stop receiving messages + // subscriber?.stopAsync() + } + } + + private fun setupPublisherToTopic( + channelProvider: TransportChannelProvider, + projectId: String, + topicId: String): Publisher { + + logger.info("Setting up Publisher for topic: {}", topicId) + val topicName = ProjectTopicName.of(projectId, topicId) + return Publisher.newBuilder(topicName) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider()) + .build() + } + + /** + * Start docker compose locally before running this test. + * + * 'docker-compose -f docker-compose.ocs.yaml up --build' + */ + @Test + fun `load test OCS using gRPC`() { + + val users = createTestUsers() + + // Setup gRPC client + val channel = ManagedChannelBuilder + .forTarget(ocsSocket) + .usePlaintext() + .build() + + val ocsService = OcsServiceGrpc.newStub(channel) + + // count down latch to wait for all responses to return + val cdl = CountDownLatch(COUNT) + + // response handle which will count down on receiving response + val requestStream = ocsService.creditControlRequest(object : StreamObserver { + + // count down on receiving response + override fun onNext(value: CreditControlAnswerInfo?) = cdl.countDown() + + override fun onError(t: Throwable?) = fail(t?.message) + + override fun onCompleted() { + } + }) + + benchmark { + + // Send the same request COUNT times + (0 until COUNT) + .toList() + .parallelStream() + .map { i -> + + // Sample request which will be sent repeatedly + val request = CreditControlRequestInfo.newBuilder() + .setRequestId(UUID.randomUUID().toString()) + .setType(UPDATE_REQUEST) + .setMsisdn(users[i % USER_COUNT].msisdn) + .addMscc(0, MultipleServiceCreditControl.newBuilder() + .setRequested(ServiceUnit.newBuilder().setTotalOctets(REQUESTED_BYTES)) + .setUsed(ServiceUnit.newBuilder().setTotalOctets(USED_BYTES))) + .build() + + requestStream.onNext(request) + } + .toList() + + // Wait for all the responses to be returned + println("Waiting for all responses to be returned") + cdl.await() + } + + requestStream.onCompleted() + + + //for (i in 0 until USER_COUNT) { + assertUserBalance( + email = users[0].email, + expectedBalance = 100_000_000 - COUNT * USED_BYTES / USER_COUNT + USED_BYTES - REQUESTED_BYTES) + //} + } + + private fun createTestUsers(): List = (1..USER_COUNT) + .toList() + .parallelStream() + .map { i -> + val email = "ocs-load-test-$i@test.com" + + // Create Customer + createCustomer(name = "OCS Load Test", email = email) + + // Assign MSISDN to customer + val msisdn = createSubscription(email = email) + + User(email = email, msisdn = msisdn) + } + .toList() + + private fun benchmark(task: () -> Unit) { + // Start timestamp in millisecond + val start = Instant.now() + + // perform task + task() + + // Stop timestamp in millisecond + val stop = Instant.now() + + // Print load test results + val diff = stop.toEpochMilli() - start.toEpochMilli() + println("Time diff: %,d milli sec".format(diff)) + val rate = COUNT * 1000.0 / diff + println("Rate: %,.2f req/sec".format(rate)) + } + + private fun assertUserBalance(email: String, expectedBalance: Long) { + // check balance + val bundles: BundleList = get { + path = "/bundles" + this.email = email + } + + val balance = bundles[0].balance + + // initial balance = 100 MB + // consumed = COUNT * USED_BYTES + // reserved = REQUESTED_BYTES + assertEquals( + expected = expectedBalance, + actual = balance, + message = "Balance does not match after load test consumption") + } + + companion object { + private const val USER_COUNT = 100 + private const val COUNT = 10_000 + private const val USED_BYTES = 10L + private const val REQUESTED_BYTES = 100L + } +} + +data class User(val email: String, val msisdn: String) \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/ClientFactory.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/ClientFactory.kt index d54172033..e9f07185b 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/ClientFactory.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/ClientFactory.kt @@ -2,14 +2,17 @@ package org.ostelco.at.okhttp import org.ostelco.at.common.Auth.generateAccessToken import org.ostelco.at.common.url -import org.ostelco.prime.client.ApiClient -import org.ostelco.prime.client.api.DefaultApi +import org.ostelco.prime.customer.ApiClient +import org.ostelco.prime.customer.api.DefaultApi object ClientFactory { fun clientForSubject(subject: String): DefaultApi { val apiClient = ApiClient().setBasePath(url) - apiClient.setAccessToken(generateAccessToken(subject = subject)) + apiClient.connectTimeout = 0 + apiClient.readTimeout = 0 + apiClient.writeTimeout = 0 + apiClient.setAccessToken(generateAccessToken(email = subject)) return DefaultApi(apiClient) } } \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt index d11931d30..f91a6fcc9 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt @@ -1,190 +1,255 @@ package org.ostelco.at.okhttp +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.Ignore import org.junit.Test import org.ostelco.at.common.StripePayment -import org.ostelco.at.common.createProfile +import org.ostelco.at.common.createCustomer import org.ostelco.at.common.createSubscription +import org.ostelco.at.common.enableRegion import org.ostelco.at.common.expectedProducts import org.ostelco.at.common.getLogger import org.ostelco.at.common.randomInt +import org.ostelco.at.jersey.post import org.ostelco.at.okhttp.ClientFactory.clientForSubject -import org.ostelco.prime.client.ApiException -import org.ostelco.prime.client.api.DefaultApi -import org.ostelco.prime.client.model.ApplicationToken -import org.ostelco.prime.client.model.Consent -import org.ostelco.prime.client.model.PaymentSource -import org.ostelco.prime.client.model.Person -import org.ostelco.prime.client.model.PersonList -import org.ostelco.prime.client.model.Price -import org.ostelco.prime.client.model.Product -import org.ostelco.prime.client.model.Profile -import org.ostelco.prime.client.model.SubscriptionStatus +import org.ostelco.prime.customer.api.DefaultApi +import org.ostelco.prime.customer.model.ApplicationToken +import org.ostelco.prime.customer.model.Customer +import org.ostelco.prime.customer.model.GraphQLRequest +import org.ostelco.prime.customer.model.KycStatus +import org.ostelco.prime.customer.model.KycType +import org.ostelco.prime.customer.model.PaymentSource +import org.ostelco.prime.customer.model.Person +import org.ostelco.prime.customer.model.PersonList +import org.ostelco.prime.customer.model.Price +import org.ostelco.prime.customer.model.Product +import org.ostelco.prime.customer.model.Region +import org.ostelco.prime.customer.model.RegionDetails +import org.ostelco.prime.customer.model.RegionDetails.StatusEnum.APPROVED +import org.ostelco.prime.customer.model.RegionDetails.StatusEnum.PENDING +import org.ostelco.prime.customer.model.ScanInformation +import org.ostelco.prime.customer.model.SimProfile +import org.ostelco.prime.customer.model.SimProfileList import java.time.Instant import java.util.* +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.MultivaluedHashMap +import kotlin.collections.set import kotlin.test.assertEquals import kotlin.test.assertFails -import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue -class ProfileTest { +class CustomerTest { @Test - fun `okhttp test - GET and PUT profile`() { + fun `okhttp test - GET and PUT customer`() { - val email = "profile-${randomInt()}@test.com" - - val client = clientForSubject(subject = email) - - val createProfile = Profile() - .email(email) - .name("Test Profile User") - .address("") - .city("") - .country("NO") - .postCode("") - .referralId("") - - client.createProfile(createProfile, null) - - val profile: Profile = client.profile - - assertEquals(email, profile.email, "Incorrect 'email' in fetched profile") - assertEquals(createProfile.name, profile.name, "Incorrect 'name' in fetched profile") - assertEquals(email, profile.referralId, "Incorrect 'referralId' in fetched profile") + val email = "customer-${randomInt()}@test.com" + var customerId = "" + try { + val client = clientForSubject(subject = email) - profile - .address("Some place") - .postCode("418") - .city("Udacity") - .country("Online") + val nickname = "Test Customer" - val updatedProfile: Profile = client.updateProfile(profile) + client.createCustomer(nickname, email, null) - assertEquals(email, updatedProfile.email, "Incorrect 'email' in response after updating profile") - assertEquals(createProfile.name, updatedProfile.name, "Incorrect 'name' in response after updating profile") - assertEquals("Some place", updatedProfile.address, "Incorrect 'address' in response after updating profile") - assertEquals("418", updatedProfile.postCode, "Incorrect 'postcode' in response after updating profile") - assertEquals("Udacity", updatedProfile.city, "Incorrect 'city' in response after updating profile") - assertEquals("Online", updatedProfile.country, "Incorrect 'country' in response after updating profile") + val customer: Customer = client.customer + customerId = customer.id - updatedProfile - .address("") - .postCode("") - .city("") + assertEquals(email, customer.contactEmail, "Incorrect 'contactEmail' in fetched customer") + assertEquals(nickname, customer.nickname, "Incorrect 'name' in fetched customer") - val clearedProfile: Profile = client.updateProfile(updatedProfile) + val newNickname = "New name: Test Customer" - assertEquals(email, clearedProfile.email, "Incorrect 'email' in response after clearing profile") - assertEquals(createProfile.name, clearedProfile.name, "Incorrect 'name' in response after clearing profile") - assertEquals("", clearedProfile.address, "Incorrect 'address' in response after clearing profile") - assertEquals("", clearedProfile.postCode, "Incorrect 'postcode' in response after clearing profile") - assertEquals("", clearedProfile.city, "Incorrect 'city' in response after clearing profile") + customer.nickname(newNickname) - updatedProfile.country("") + val updatedCustomer: Customer = client.updateCustomer(customer.nickname, null) - assertFailsWith(ApiException::class, "Incorrectly accepts that 'country' is cleared/not set") { - client.updateProfile(updatedProfile) + assertEquals(email, updatedCustomer.contactEmail, "Incorrect 'contactEmail' in response after updating customer") + assertEquals(newNickname, updatedCustomer.nickname, "Incorrect 'nickname' in response after updating customer") + } finally { + StripePayment.deleteCustomer(customerId = customerId) } } @Test - fun `okhttp test - GET application token`() { + fun `okhttp test - POST application token`() { val email = "token-${randomInt()}@test.com" - createProfile("Test Token User", email) + var customerId = "" + try { + customerId = createCustomer("Test Token User", email).id - createSubscription(email) + createSubscription(email) - val token = UUID.randomUUID().toString() - val applicationId = "testApplicationId" - val tokenType = "FCM" + val token = UUID.randomUUID().toString() + val applicationId = "testApplicationId" + val tokenType = "FCM" - val testToken = ApplicationToken() - .token(token) - .applicationID(applicationId) - .tokenType(tokenType) + val testToken = ApplicationToken() + .token(token) + .applicationID(applicationId) + .tokenType(tokenType) - val client = clientForSubject(subject = email) + val client = clientForSubject(subject = email) - val reply = client.storeApplicationToken(testToken) + val reply = client.storeApplicationToken(testToken) - assertEquals(token, reply.token, "Incorrect token in reply after posting new token") - assertEquals(applicationId, reply.applicationID, "Incorrect applicationId in reply after posting new token") - assertEquals(tokenType, reply.tokenType, "Incorrect tokenType in reply after posting new token") + assertEquals(token, reply.token, "Incorrect token in reply after posting new token") + assertEquals(applicationId, reply.applicationID, "Incorrect applicationId in reply after posting new token") + assertEquals(tokenType, reply.tokenType, "Incorrect tokenType in reply after posting new token") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } -class GetSubscriptions { +class RegionsTest { @Test - fun `okhttp test - GET subscriptions`() { + fun `okhttp test - GET regions - No regions`() { - val email = "subs-${randomInt()}@test.com" - createProfile(name = "Test Subscriptions User", email = email) - val msisdn = createSubscription(email) + val email = "regions-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test No Region User", email = email).id - val client = clientForSubject(subject = email) + val client = clientForSubject(subject = email) - val subscriptions = client.subscriptions + val regionDetailsList: Collection = client.allRegions - assertEquals(listOf(msisdn), subscriptions.map { it.msisdn }) + assertTrue(regionDetailsList.isEmpty(), "RegionDetails list for new customer should be empty") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } -} -class GetSubscriptionStatusTest { + @Test + fun `okhttp test - GET regions - Single Region with no profiles`() { - private val logger by getLogger() + val email = "regions-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Single Region User", email = email).id + enableRegion(email = email) + + val client = clientForSubject(subject = email) + + val regionDetailsList: Collection = client.allRegions + + assertEquals(1, regionDetailsList.size, "Customer should have one region") + + val regionDetails = RegionDetails() + .region(Region().id("no").name("Norway")) + .status(APPROVED) + .kycStatusMap(mapOf(KycType.JUMIO.name to KycStatus.APPROVED)) + .simProfiles(SimProfileList()) + + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } + @Ignore @Test - fun `okhttp test - GET subscription status`() { + fun `okhttp test - GET regions - Single Region with one profile`() { - val email = "balance-${randomInt()}@test.com" - createProfile(name = "Test Balance User", email = email) + val email = "regions-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Single Region User", email = email).id + enableRegion(email = email) - val client = clientForSubject(subject = email) + val client = clientForSubject(subject = email) + + client.provisionSimProfile("no", null) + + val regionDetailsList = client.allRegions + + assertEquals(1, regionDetailsList.size, "Customer should have one region") + + val receivedRegion = regionDetailsList.first() - val subscriptionStatus = client.subscriptionStatus + assertEquals(Region().id("no").name("Norway"), receivedRegion.region, "Region do not match") - logger.info("Balance: ${subscriptionStatus.remaining}") + assertEquals(APPROVED, receivedRegion.status, "Region status do not match") - val freeProduct = Product() - .sku("100MB_FREE_ON_JOINING") - .price(Price().apply { - this.amount = 0 - this.currency = "NOK" - }) - .properties(mapOf("noOfBytes" to "100_000_000")) - .presentation(emptyMap()) + assertEquals( + mapOf(KycType.JUMIO.name to KycStatus.APPROVED), + receivedRegion.kycStatusMap, + "Kyc status map do not match") - val purchaseRecords = subscriptionStatus.purchaseRecords - purchaseRecords.sortBy { it.timestamp } + assertEquals( + 1, + receivedRegion.simProfiles.size, + "Should have only one sim profile") - assertEquals(freeProduct, purchaseRecords.first().product, "Incorrect first 'Product' in purchase record") + assertNotNull(receivedRegion.simProfiles.single().iccId) + assertEquals("", receivedRegion.simProfiles.single().alias) + assertNotNull(receivedRegion.simProfiles.single().eSimActivationCode) + assertEquals(SimProfile.StatusEnum.AVAILABLE_FOR_DOWNLOAD, receivedRegion.simProfiles.single().status) + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } -class GetPseudonymsTest { +class SubscriptionsTest { + + @Test + fun `okhttp test - GET subscriptions`() { + + val email = "subs-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Subscriptions User", email = email).id + val msisdn = createSubscription(email) + + val client = clientForSubject(subject = email) + + val subscriptions = client.subscriptions + + assertEquals(listOf(msisdn), subscriptions.map { it.msisdn }) + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } +} + +class BundlesAndPurchasesTest { private val logger by getLogger() @Test - fun `okhttp test - GET active pseudonyms`() { + fun `okhttp test - GET bundles`() { - val email = "pseu-${randomInt()}@test.com" - createProfile(name = "Test Pseudonyms User", email = email) + val email = "balance-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Balance User", email = email).id - val client = clientForSubject(subject = email) + val client = clientForSubject(subject = email) + + val bundles = client.bundles - createSubscription(email) + logger.info("Balance: ${bundles[0].balance}") - val activePseudonyms = client.activePseudonyms + val freeProduct = Product() + .sku("2GB_FREE_ON_JOINING") + .price(Price().amount(0).currency("")) + .properties(mapOf("noOfBytes" to "2_147_483_648")) + .presentation(emptyMap()) - logger.info("Current: ${activePseudonyms.current.pseudonym}") - logger.info("Next: ${activePseudonyms.next.pseudonym}") - assertNotNull(activePseudonyms.current.pseudonym, "Empty current pseudonym") - assertNotNull(activePseudonyms.next.pseudonym, "Empty next pseudonym") - assertEquals(activePseudonyms.current.end + 1, activePseudonyms.next.start, "The pseudonyms are not in order") + val purchaseRecords = client.purchaseHistory + purchaseRecords.sortBy { it.timestamp } + + assertEquals(freeProduct, purchaseRecords.first().product, "Incorrect first 'Product' in purchase record") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } @@ -194,13 +259,19 @@ class GetProductsTest { fun `okhttp test - GET products`() { val email = "products-${randomInt()}@test.com" - createProfile(name = "Test Products User", email = email) + var customerId = "" + try { + customerId = createCustomer(name = "Test Products User", email = email).id + enableRegion(email = email) - val client = clientForSubject(subject = email) + val client = clientForSubject(subject = email) - val products = client.allProducts.toList() + val products = client.allProducts.toList() - assertEquals(expectedProducts().toSet(), products.toSet(), "Incorrect 'Products' fetched") + assertEquals(expectedProducts().toSet(), products.toSet(), "Incorrect 'Products' fetched") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } @@ -210,8 +281,9 @@ class SourceTest { fun `okhttp test - POST source create`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Payment Source", email = email) + customerId = createCustomer(name = "Test create Payment Source", email = email).id val client = clientForSubject(subject = email) @@ -229,7 +301,7 @@ class SourceTest { assertNotNull(sources.first { it.id == cardId }, "Expected card $cardId in list of payment sources for profile $email") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -237,8 +309,9 @@ class SourceTest { fun `okhttp test - GET list sources`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Payment Source", email = email) + customerId = createCustomer(name = "Test list Payment Source", email = email).id val client = clientForSubject(subject = email) @@ -254,7 +327,7 @@ class SourceTest { val ids = createdIds.map { getCardIdForTokenFromStripe(it) } assert(sources.isNotEmpty()) { "Expected at least one payment source for profile $email" } - assert(sources.map{ it.id }.containsAll(ids)) + assert(sources.map { it.id }.containsAll(ids)) { "Expected to find all of $ids in list of sources for profile $email" } sources.forEach { @@ -264,7 +337,7 @@ class SourceTest { } } } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -272,7 +345,10 @@ class SourceTest { fun `okhttp test - GET list sources no profile`() { val email = "purchase-${randomInt()}@test.com" + + var customerId = "" try { + customerId = createCustomer(name = "Test get list Sources", email = email).id val client = clientForSubject(subject = email) @@ -282,10 +358,10 @@ class SourceTest { assert(sources.isEmpty()) { "Expected no payment source for profile $email" } - assertNotNull(StripePayment.getCustomerIdForEmail(email)) { "Customer Id should have been created" } + assertNotNull(StripePayment.getStripeCustomerId(customerId = customerId)) { "Customer Id should have been created" } } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -293,8 +369,9 @@ class SourceTest { fun `okhttp test - PUT source set default`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Payment Source", email = email) + customerId = createCustomer(name = "Test update Payment Source", email = email).id val client = clientForSubject(subject = email) @@ -312,19 +389,19 @@ class SourceTest { client.createSource(newTokenId) // TODO: Update to fetch the Stripe customerId from 'admin' API when ready. - val customerId = StripePayment.getCustomerIdForEmail(email) + val stripeCustomerId = StripePayment.getStripeCustomerId(customerId = customerId) // Verify that original 'sourceId/card' is default. - assertEquals(cardId, StripePayment.getDefaultSourceForCustomer(customerId), - "Expected $cardId to be default source for $customerId") + assertEquals(cardId, StripePayment.getDefaultSourceForCustomer(stripeCustomerId), + "Expected $cardId to be default source for $stripeCustomerId") // Set new default card. client.setDefaultSource(newCardId) - assertEquals(newCardId, StripePayment.getDefaultSourceForCustomer(customerId), - "Expected $newCardId to be default source for $customerId") + assertEquals(newCardId, StripePayment.getDefaultSourceForCustomer(stripeCustomerId), + "Expected $newCardId to be default source for $stripeCustomerId") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -332,9 +409,10 @@ class SourceTest { fun `okhttp test - DELETE source`() { val email = "purchase-${randomInt()}@test.com" - + var customerId = "" try { - createProfile(name = "Test Payment Source", email = email) + + customerId = createCustomer(name = "Test delete Payment Source", email = email).id val client = clientForSubject(subject = email) @@ -343,26 +421,26 @@ class SourceTest { val createdIds = listOf(getCardIdForTokenFromStripe(createTokenWithStripe(client)), createSourceWithStripe(client)) - val deletedIds = createdIds.map { it -> deleteSourceWithStripe(client, it) } - + val deletedIds = createdIds.map { deleteSourceWithStripe(client, it) } + assert(createdIds.containsAll(deletedIds.toSet())) { "Failed to delete one or more sources: ${createdIds.toSet() - deletedIds.toSet()}" } } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } // Helpers for source handling with Stripe. - private fun getCardIdForTokenFromStripe(id: String) : String { + private fun getCardIdForTokenFromStripe(id: String): String { if (id.startsWith("tok_")) { return StripePayment.getCardIdForTokenId(id) } return id } - private fun createTokenWithStripe(client: DefaultApi) : String { + private fun createTokenWithStripe(client: DefaultApi): String { val tokenId = StripePayment.createPaymentTokenId() client.createSource(tokenId) @@ -370,7 +448,7 @@ class SourceTest { return tokenId } - private fun createSourceWithStripe(client: DefaultApi) : String { + private fun createSourceWithStripe(client: DefaultApi): String { val sourceId = StripePayment.createPaymentSourceId() client.createSource(sourceId) @@ -378,7 +456,7 @@ class SourceTest { return sourceId } - private fun deleteSourceWithStripe(client : DefaultApi, sourceId : String) : String { + private fun deleteSourceWithStripe(client: DefaultApi, sourceId: String): String { val removedSource = client.removeSource(sourceId) @@ -392,8 +470,10 @@ class PurchaseTest { fun `okhttp test - POST products purchase`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Purchase User", email = email) + customerId = createCustomer(name = "Test Purchase User", email = email).id + enableRegion(email = email) val client = clientForSubject(subject = email) @@ -407,7 +487,7 @@ class PurchaseTest { val balanceAfter = client.bundles.first().balance - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + assertEquals(1_073_741_824, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") val purchaseRecords = client.purchaseHistory @@ -416,7 +496,7 @@ class PurchaseTest { assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -424,8 +504,10 @@ class PurchaseTest { fun `okhttp test - POST products purchase using default source`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Purchase User with Default Payment Source", email = email) + customerId = createCustomer(name = "Test Purchase with Default Payment Source", email = email).id + enableRegion(email = email) val sourceId = StripePayment.createPaymentTokenId() @@ -445,7 +527,7 @@ class PurchaseTest { val balanceAfter = client.bundles.first().balance - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + assertEquals(1_073_741_824, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") val purchaseRecords = client.purchaseHistory @@ -454,7 +536,7 @@ class PurchaseTest { assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } @@ -462,8 +544,10 @@ class PurchaseTest { fun `okhttp test - POST products purchase add source then pay with it`() { val email = "purchase-${randomInt()}@test.com" + var customerId = "" try { - createProfile(name = "Test Purchase User with Default Payment Source", email = email) + customerId = createCustomer(name = "Test Purchase with adding Payment Source", email = email).id + enableRegion(email = email) val sourceId = StripePayment.createPaymentTokenId() @@ -473,7 +557,7 @@ class PurchaseTest { assertNotNull(paymentSource.id, message = "Failed to create payment source") - val balanceBefore = client.subscriptionStatus.remaining + val balanceBefore = client.bundles[0].balance val productSku = "1GB_249NOK" @@ -481,9 +565,9 @@ class PurchaseTest { Thread.sleep(200) // wait for 200 ms for balance to be updated in db - val balanceAfter = client.subscriptionStatus.remaining + val balanceAfter = client.bundles[0].balance - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + assertEquals(1_073_741_824, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") val purchaseRecords = client.purchaseHistory @@ -492,73 +576,168 @@ class PurchaseTest { assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") } finally { - StripePayment.deleteCustomer(email = email) + StripePayment.deleteCustomer(customerId = customerId) } } +} + +// TODO Prasanth: add okhttp acceptance tests for Jumio + +class SingaporeKycTest { @Test - fun `okhttp test - POST products purchase without payment`() { + fun `okhttp test - GET myinfo`() { - val email = "purchase-legacy-${randomInt()}@test.com" - createProfile(name = "Test Legacy Purchase User", email = email) + val email = "myinfo-${randomInt()}@test.com" + var customerId = "" + try { - val client = clientForSubject(subject = email) + customerId = createCustomer(name = "Test MyInfo Customer", email = email).id - val balanceBefore = client.bundles.first().balance + val client = clientForSubject(subject = email) + + run { + val regionDetailsList = client.allRegions - client.buyProductDeprecated("1GB_249NOK") + assertTrue(regionDetailsList.isEmpty(), "regionDetailsList should be empty") + } - Thread.sleep(200) // wait for 200 ms for balance to be updated in db + val personData: String = jacksonObjectMapper().writeValueAsString(client.getCustomerMyInfoData("authCode")) - val balanceAfter = client.bundles.first().balance + val expectedPersonData = """{"name":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"TANXIAOHUI"},"sex":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"F"},"nationality":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"SG"},"dob":{"lastupdated":"2018-03-20","source":"1","classification":"C","value":"1970-05-17"},"email":{"lastupdated":"2018-08-23","source":"4","classification":"C","value":"myinfotesting@gmail.com"},"mobileno":{"lastupdated":"2018-08-23","code":"65","source":"4","classification":"C","prefix":"+","nbr":"97399245"},"regadd":{"country":"SG","unit":"128","street":"BEDOKNORTHAVENUE4","lastupdated":"2018-03-20","block":"102","postal":"460102","source":"1","classification":"C","floor":"09","building":"PEARLGARDEN"},"uinfin":"S9812381D"}""" + assertEquals(expectedPersonData, personData, "MyInfo PersonData do not match") - assertEquals(1_000_000_000, balanceAfter - balanceBefore, "Balance did not increased by 1GB after Purchase") + run { + val regionDetailsList = client.allRegions - val purchaseRecords = client.purchaseHistory + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") - purchaseRecords.sortBy { it.timestamp } + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(APPROVED) + .kycStatusMap(mutableMapOf( + KycType.JUMIO.name to KycStatus.PENDING, + KycType.MY_INFO.name to KycStatus.APPROVED, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.PENDING, + KycType.NRIC_FIN.name to KycStatus.PENDING)) + .simProfiles(SimProfileList()) - assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } - assertEquals(expectedProducts().first(), purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } -} -class ConsentTest { + @Test + fun `okhttp test - NRIC, Jumio and address`() { + + val email = "myinfo-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test MyInfo Customer", email = email).id - private val consentId = "privacy" + val client = clientForSubject(subject = email) - @Test - fun `okhttp test - GET and PUT consent`() { + run { + val regionDetailsList = client.allRegions - val email = "consent-${randomInt()}@test.com" - createProfile(name = "Test Consent User", email = email) + assertTrue(regionDetailsList.isEmpty(), "regionDetailsList should be empty") + } - val client = clientForSubject(subject = email) + client.checkNricFinId("S7808018C") - val defaultConsent: List = client.consents.toList() + run { + val regionDetailsList = client.allRegions - assertEquals(1, defaultConsent.size, "Incorrect number of consents fetched") - assertEquals(consentId, defaultConsent[0].consentId, "Incorrect 'consent id' in fetched consent") + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") - // TODO vihang: Update consent operation is missing response entity -// val acceptedConsent: Consent = - client.updateConsent(consentId, true) + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(PENDING) + .kycStatusMap(mutableMapOf( + KycType.MY_INFO.name to KycStatus.PENDING, + KycType.NRIC_FIN.name to KycStatus.APPROVED, + KycType.JUMIO.name to KycStatus.PENDING, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.PENDING)) + .simProfiles(SimProfileList()) -// assertEquals(consentId, acceptedConsent.consentId, "Incorrect 'consent id' in response after accepting consent") -// assertTrue(acceptedConsent.isAccepted ?: false, "Accepted consent not reflected in response after accepting consent") + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } -// val rejectedConsent: Consent = - client.updateConsent(consentId, false) + val scanInfo: ScanInformation = client.createNewJumioKycScanId("sg") + + assertNotNull(scanInfo.scanId, message = "Failed to get new scanId") + + val dataMap = MultivaluedHashMap() + dataMap["jumioIdScanReference"] = listOf(UUID.randomUUID().toString()) + dataMap["idScanStatus"] = listOf("SUCCESS") + dataMap["verificationStatus"] = listOf("APPROVED_VERIFIED") + dataMap["callbackDate"] = listOf("2018-12-07T09:19:07.036Z") + dataMap["idType"] = listOf("LICENSE") + dataMap["idCountry"] = listOf("NOR") + dataMap["idFirstName"] = listOf("Test User") + dataMap["idLastName"] = listOf("Test Family") + dataMap["idDob"] = listOf("1990-12-09") + dataMap["merchantIdScanReference"] = listOf(scanInfo.scanId) + val identityVerification = """{ "similarity":"MATCH", "validity":"TRUE"}""" + dataMap["identityVerification"] = listOf(identityVerification) + val imgUrl = "https://www.gstatic.com/webp/gallery3/1.png" + val imgUrl2 = "https://www.gstatic.com/webp/gallery3/2.png" + dataMap["livenessImages"] = listOf(imgUrl, imgUrl2) + + post(expectedResultCode = 200, dataType = MediaType.APPLICATION_FORM_URLENCODED_TYPE) { + path = "/ekyc/callback" + body = dataMap + } -// assertEquals(consentId, rejectedConsent.consentId, "Incorrect 'consent id' in response after rejecting consent") -// assertFalse(rejectedConsent.isAccepted ?: true, "Accepted consent not reflected in response after rejecting consent") + run { + val regionDetailsList = client.allRegions + + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") + + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(PENDING) + .kycStatusMap(mutableMapOf( + KycType.MY_INFO.name to KycStatus.PENDING, + KycType.NRIC_FIN.name to KycStatus.APPROVED, + KycType.JUMIO.name to KycStatus.APPROVED, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.PENDING)) + .simProfiles(SimProfileList()) + + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } + + client.updateDetails("Singapore", "1234") + + run { + val regionDetailsList = client.allRegions + + assertEquals(1, regionDetailsList.size, "regionDetailsList should have only one entry") + + val regionDetails = RegionDetails() + .region(Region().id("sg").name("Singapore")) + .status(APPROVED) + .kycStatusMap(mutableMapOf( + KycType.JUMIO.name to KycStatus.APPROVED, + KycType.MY_INFO.name to KycStatus.PENDING, + KycType.ADDRESS_AND_PHONE_NUMBER.name to KycStatus.APPROVED, + KycType.NRIC_FIN.name to KycStatus.APPROVED)) + .simProfiles(SimProfileList()) + + assertEquals(regionDetails, regionDetailsList.single(), "RegionDetails do not match") + } + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } class ReferralTest { @Test - fun `okhttp test - POST profile with invalid referred by`() { + fun `okhttp test - POST customer with invalid referred by`() { val email = "referred_by_invalid-${randomInt()}@test.com" @@ -566,86 +745,108 @@ class ReferralTest { val invalid = "invalid_referrer@test.com" - val profile = Profile() - .email(email) - .name("Test Referral Second User") - .address("") - .city("") - .country("") - .postCode("") - .referralId("") + val customer = Customer() + .contactEmail(email) + .nickname("Test Referral Second User") val failedToCreate = assertFails { - client.createProfile(profile, invalid) + client.createCustomer(customer.nickname, customer.contactEmail, invalid) } assertEquals(""" -{"description":"Incomplete profile description. Subscriber - $invalid not found."} expected:<201> but was:<403> +{"description":"Incomplete customer description. Subscriber - $invalid not found."} expected:<201> but was:<403> """.trimIndent(), failedToCreate.message) val failedToGet = assertFails { - client.profile + client.customer } assertEquals(""" -{"description":"Incomplete profile description. Subscriber - $email not found."} expected:<200> but was:<404> +{"description":"Incomplete customer description. Subscriber - $email not found."} expected:<200> but was:<404> """.trimIndent(), failedToGet.message) } @Test - fun `okhttp test - POST profile`() { + fun `okhttp test - POST customer`() { val firstEmail = "referral_first-${randomInt()}@test.com" - createProfile(name = "Test Referral First User", email = firstEmail) - - val secondEmail = "referral_second-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer(name = "Test Referral First User", email = firstEmail).id - val profile = Profile() - .email(secondEmail) - .name("Test Referral Second User") - .address("") - .city("") - .country("") - .postCode("") - .referralId("") + val secondEmail = "referral_second-${randomInt()}@test.com" - val firstEmailClient = clientForSubject(subject = firstEmail) - val secondEmailClient = clientForSubject(subject = secondEmail) + val customer = Customer() + .contactEmail(secondEmail) + .nickname("Test Referral Second User") + .referralId("") - secondEmailClient.createProfile(profile, firstEmail) + val firstEmailClient = clientForSubject(subject = firstEmail) + val secondEmailClient = clientForSubject(subject = secondEmail) - // for first - val referralsForFirst: PersonList = firstEmailClient.referred + secondEmailClient.createCustomer(customer.nickname, firstEmail, null) - assertEquals(listOf("Test Referral Second User"), referralsForFirst.map { it.name }) + // for first + val referralsForFirst: PersonList = firstEmailClient.referred - val referredByForFirst: Person = firstEmailClient.referredBy - assertNull(referredByForFirst.name) + assertEquals(listOf("Test Referral Second User"), referralsForFirst.map { it.name }) - // No need to test SubscriptionStatus for first, since it is already tested in GetSubscriptionStatusTest. + val referredByForFirst: Person = firstEmailClient.referredBy + assertNull(referredByForFirst.name) - // for referred_by_foo - val referralsForSecond: List = secondEmailClient.referred + // No need to test SubscriptionStatus for first, since it is already tested in GetSubscriptionStatusTest. - assertEquals(emptyList(), referralsForSecond.map { it.name }) + // for referred_by_foo + val referralsForSecond: List = secondEmailClient.referred - val referredByForSecond: Person = secondEmailClient.referredBy + assertEquals(emptyList(), referralsForSecond.map { it.name }) - assertEquals("Test Referral First User", referredByForSecond.name) + val referredByForSecond: Person = secondEmailClient.referredBy - val secondSubscriptionStatus: SubscriptionStatus = secondEmailClient.subscriptionStatus + assertEquals("Test Referral First User", referredByForSecond.name) - assertEquals(1_000_000_000, secondSubscriptionStatus.remaining) + assertEquals(1_000_000_000, secondEmailClient.bundles[0].balance) - val freeProductForReferred = Product() - .sku("1GB_FREE_ON_REFERRED") - .price(Price().apply { - this.amount = 0 - this.currency = "NOK" - }) - .properties(mapOf("noOfBytes" to "1_000_000_000")) - .presentation(emptyMap()) + val freeProductForReferred = Product() + .sku("1GB_FREE_ON_REFERRED") + .price(Price().amount(0).currency("NOK")) + .properties(mapOf("noOfBytes" to "1_000_000_000")) + .presentation(emptyMap()) - assertEquals(listOf(freeProductForReferred), secondSubscriptionStatus.purchaseRecords.map { it.product }) + assertEquals(listOf(freeProductForReferred), secondEmailClient.purchaseHistory.map { it.product }) + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } } } + +// TODO Kjell: add okhttp acceptance tests for PlanTest + +class GraphQlTests { + + @Test + fun `okhttp test - POST graphql`() { + + val email = "graphql-${randomInt()}@test.com" + var customerId = "" + try { + customerId = createCustomer("Test GraphQL Endpoint", email).id + + createSubscription(email) + + val client = clientForSubject(subject = email) + + val request = GraphQLRequest() + request.query = """{ context(id: "$email") { customer { nickname, contactEmail } } }""" + + val map = client.graphql(request) as Map + + println(map) + + assertNotNull(actual = map["data"], message = "Data is null") + assertNull(actual = map["error"], message = "Error is not null") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } +} \ No newline at end of file diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/pgw/OcsTest.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/pgw/OcsTest.kt index 44af16e46..df01d74ca 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/pgw/OcsTest.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/pgw/OcsTest.kt @@ -4,17 +4,17 @@ import org.jdiameter.api.Avp import org.jdiameter.api.Session import org.junit.After import org.junit.Before -import org.junit.BeforeClass import org.junit.Test -import org.ostelco.at.common.createProfile +import org.ostelco.at.common.createCustomer import org.ostelco.at.common.createSubscription import org.ostelco.at.common.getLogger import org.ostelco.at.common.randomInt import org.ostelco.at.jersey.get +import org.ostelco.diameter.model.FinalUnitAction import org.ostelco.diameter.model.RequestType import org.ostelco.diameter.test.TestClient import org.ostelco.diameter.test.TestHelper -import org.ostelco.prime.client.model.Bundle +import org.ostelco.prime.customer.model.Bundle import java.lang.Thread.sleep import kotlin.test.assertEquals import kotlin.test.fail @@ -29,12 +29,15 @@ class OcsTest { private val logger by getLogger() + //configuration file + private val configFile = "client-jdiameter-config.xml" + private var testClient: TestClient? = null @Before fun setUp() { testClient = TestClient() - testClient?.initStack("/") + testClient?.initStack("/", configFile) } @After @@ -43,7 +46,12 @@ class OcsTest { testClient = null } - private fun simpleCreditControlRequestInit(session: Session) { + private fun simpleCreditControlRequestInit(session : Session, + msisdn : String, + requestedBucketSize : Long, + expectedGrantedBucketSize : Long, + ratingGroup : Int, + serviceIdentifier : Int) { val client = testClient ?: fail("Test client is null") @@ -53,26 +61,36 @@ class OcsTest { session ) ?: fail("Failed to create request") - TestHelper.createInitRequest(request.avps, MSISDN, BUCKET_SIZE) + TestHelper.createInitRequest(request.avps, msisdn, requestedBucketSize, ratingGroup, serviceIdentifier) client.sendNextRequest(request, session) waitForAnswer() - assertEquals(2001L, client.resultCodeAvp?.integer32?.toLong()) + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp?.integer32?.toLong()) val resultAvps = client.resultAvps ?: fail("Missing AVPs") assertEquals(RequestType.INITIAL_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) val resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) - assertEquals(2001L, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) - assertEquals(1, resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).unsigned32) - assertEquals(10, resultMSCC.grouped.getAvp(Avp.RATING_GROUP).unsigned32) + assertEquals(DIAMETER_SUCCESS, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) + if (serviceIdentifier > 0) { + assertEquals(serviceIdentifier.toLong(), resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).unsigned32) + } + if (ratingGroup > 0) { + assertEquals(ratingGroup.toLong(), resultMSCC.grouped.getAvp(Avp.RATING_GROUP).unsigned32) + } val granted = resultMSCC.grouped.getAvp(Avp.GRANTED_SERVICE_UNIT) - assertEquals(BUCKET_SIZE, granted.grouped.getAvp(Avp.CC_TOTAL_OCTETS).unsigned64) + assertEquals(expectedGrantedBucketSize, granted.grouped.getAvp(Avp.CC_TOTAL_OCTETS).unsigned64) } - private fun simpleCreditControlRequestUpdate(session: Session) { + private fun simpleCreditControlRequestUpdate(session: Session, + msisdn: String, + requestedBucketSize : Long, + usedBucketSize : Long, + expectedGrantedBucketSize : Long, + ratingGroup : Int, + serviceIdentifier : Int) { val client = testClient ?: fail("Test client is null") @@ -82,43 +100,103 @@ class OcsTest { session ) ?: fail("Failed to create request") - TestHelper.createUpdateRequest(request.avps, MSISDN, BUCKET_SIZE, BUCKET_SIZE) + TestHelper.createUpdateRequest(request.avps, msisdn, requestedBucketSize, usedBucketSize, ratingGroup, serviceIdentifier) client.sendNextRequest(request, session) waitForAnswer() - assertEquals(2001L, client.resultCodeAvp?.integer32?.toLong()) + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp?.integer32?.toLong()) val resultAvps = client.resultAvps ?: fail("Missing AVPs") assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) assertEquals(RequestType.UPDATE_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) val resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) - assertEquals(2001L, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) + assertEquals(DIAMETER_SUCCESS, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) + if (serviceIdentifier > 0) { + assertEquals(serviceIdentifier.toLong(), resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).unsigned32) + } + if (ratingGroup > 0) { + assertEquals(ratingGroup.toLong(), resultMSCC.grouped.getAvp(Avp.RATING_GROUP).unsigned32) + } val granted = resultMSCC.grouped.getAvp(Avp.GRANTED_SERVICE_UNIT) - assertEquals(BUCKET_SIZE, granted.grouped.getAvp(Avp.CC_TOTAL_OCTETS).unsigned64) + assertEquals(expectedGrantedBucketSize, granted.grouped.getAvp(Avp.CC_TOTAL_OCTETS).unsigned64) } - private fun getBalance(): Long { + private fun getBalance(email: String): Long { sleep(200) // wait for 200 ms for balance to be updated in db return get> { path = "/bundles" - subscriberId = EMAIL + this.email = email }.first().balance } + + @Test + fun multiRatingGroupsInit() { + + val email = "ocs-${randomInt()}@test.com" + createCustomer(name = "Test OCS User", email = email) + + val msisdn = createSubscription(email = email) + + val client = testClient ?: fail("Test client is null") + + val session = client.createSession() ?: fail("Failed to create session") + val request = client.createRequest( + DEST_REALM, + DEST_HOST, + session + ) ?: fail("Failed to create request") + + TestHelper.createInitRequestMultiRatingGroups(request.getAvps(), msisdn, 5000L) + + client.sendNextRequest(request, session) + + waitForAnswer() + + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp!!.getInteger32().toLong()) + val resultAvps = client.resultAvps + assertEquals(DEST_HOST, resultAvps!!.getAvp(Avp.ORIGIN_HOST).getUTF8String()) + assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).getUTF8String()) + assertEquals(RequestType.INITIAL_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32().toLong()) + val resultMSCCs = resultAvps.getAvps(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) + assertEquals(3, resultMSCCs.size().toLong()) + for (i in 0 until resultMSCCs.size()) { + val mscc = resultMSCCs.getAvpByIndex(i).getGrouped() + assertEquals(DIAMETER_SUCCESS, mscc.getAvp(Avp.RESULT_CODE).getInteger32().toLong()) + val granted = mscc.getAvp(Avp.GRANTED_SERVICE_UNIT) + assertEquals(5000L, granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()) + val serviceIdentifier = mscc.getAvp(Avp.SERVICE_IDENTIFIER_CCA).getUnsigned32().toInt() + when (serviceIdentifier) { + 1 -> assertEquals(10, mscc.getAvp(Avp.RATING_GROUP).getUnsigned32()) + 2 -> assertEquals(12, mscc.getAvp(Avp.RATING_GROUP).getUnsigned32()) + 4 -> assertEquals(14, mscc.getAvp(Avp.RATING_GROUP).getUnsigned32()) + else -> fail("Unexpected Service-Identifier") + } + } + } + @Test fun simpleCreditControlRequestInitUpdateAndTerminate() { + val email = "ocs-${randomInt()}@test.com" + createCustomer(name = "Test OCS User", email = email) + + val msisdn = createSubscription(email = email) + val client = testClient ?: fail("Test client is null") + val ratingGroup = 10 + val serviceIdentifier = 1 + val session = client.createSession() ?: fail("Failed to create session") - simpleCreditControlRequestInit(session) - assertEquals(INITIAL_BALANCE - BUCKET_SIZE, getBalance(), message = "Incorrect balance after init") + simpleCreditControlRequestInit(session, msisdn, BUCKET_SIZE, BUCKET_SIZE, ratingGroup, serviceIdentifier) + assertEquals(INITIAL_BALANCE - BUCKET_SIZE, getBalance(email = email), message = "Incorrect balance after init") - simpleCreditControlRequestUpdate(session) - assertEquals(INITIAL_BALANCE - 2 * BUCKET_SIZE, getBalance(), message = "Incorrect balance after update") + simpleCreditControlRequestUpdate(session, msisdn, BUCKET_SIZE, BUCKET_SIZE, BUCKET_SIZE, ratingGroup, serviceIdentifier) + assertEquals(INITIAL_BALANCE - 2 * BUCKET_SIZE, getBalance(email = email), message = "Incorrect balance after update") val request = client.createRequest( DEST_REALM, @@ -126,31 +204,39 @@ class OcsTest { session ) ?: fail("Failed to create request") - TestHelper.createTerminateRequest(request.avps, MSISDN, BUCKET_SIZE) + TestHelper.createTerminateRequest(request.avps, msisdn, BUCKET_SIZE, ratingGroup, serviceIdentifier) client.sendNextRequest(request, session) waitForAnswer() - assertEquals(2001L, client.resultCodeAvp?.integer32?.toLong()) + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp?.integer32?.toLong()) val resultAvps = client.resultAvps ?: fail("Missing AVPs") assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) assertEquals(RequestType.TERMINATION_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) val resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) - assertEquals(2001L, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) - assertEquals(1, resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).unsigned32) - assertEquals(10, resultMSCC.grouped.getAvp(Avp.RATING_GROUP).unsigned32) + assertEquals(DIAMETER_SUCCESS, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) + assertEquals(serviceIdentifier.toLong(), resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).unsigned32) + assertEquals(ratingGroup.toLong(), resultMSCC.grouped.getAvp(Avp.RATING_GROUP).unsigned32) val validTime = resultMSCC.grouped.getAvp(Avp.VALIDITY_TIME) assertEquals(86400L, validTime.unsigned32) - assertEquals(INITIAL_BALANCE - 2 * BUCKET_SIZE, getBalance(), message = "Incorrect balance after terminate") + assertEquals(INITIAL_BALANCE - 2 * BUCKET_SIZE, getBalance(email = email), message = "Incorrect balance after terminate") } @Test fun creditControlRequestInitTerminateNoCredit() { + val email = "ocs-${randomInt()}@test.com" + createCustomer(name = "Test OCS User", email = email) + + val msisdn = createSubscription(email = email) + + val ratingGroup = 10 + val serviceIdentifier = 1 + val client = testClient ?: fail("Test client is null") val session = client.createSession() ?: fail("Failed to create session") @@ -160,25 +246,31 @@ class OcsTest { session ) ?: fail("Failed to create request") - TestHelper.createInitRequest(request.avps, "4333333333", BUCKET_SIZE) + + // Requesting one more bucket then the balance for the user + TestHelper.createInitRequest(request.avps, msisdn, INITIAL_BALANCE + BUCKET_SIZE, ratingGroup, serviceIdentifier) client.sendNextRequest(request, session) waitForAnswer() + // First request should reserve the full balance run { - assertEquals(2001L, client.resultCodeAvp?.integer32?.toLong()) + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp?.integer32?.toLong()) val resultAvps = client.resultAvps ?: fail("Missing AVPs") assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) assertEquals(RequestType.INITIAL_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) val resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) - assertEquals(2001L, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) - assertEquals(1, resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).integer32.toLong()) + assertEquals(DIAMETER_SUCCESS, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) + assertEquals(serviceIdentifier.toLong(), resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).integer32.toLong()) + assertEquals(ratingGroup.toLong(), resultMSCC.grouped.getAvp(Avp.RATING_GROUP).integer32.toLong()) val granted = resultMSCC.grouped.getAvp(Avp.GRANTED_SERVICE_UNIT) - assertEquals(0L, granted.grouped.getAvp(Avp.CC_TOTAL_OCTETS).unsigned64) + assertEquals(INITIAL_BALANCE, granted.grouped.getAvp(Avp.CC_TOTAL_OCTETS).unsigned64) + val finalUnitIndication = resultMSCC.grouped.getAvp(Avp.FINAL_UNIT_INDICATION) + assertEquals(FinalUnitAction.TERMINATE.ordinal, finalUnitIndication.grouped.getAvp(Avp.FINAL_UNIT_ACTION).integer32) } - // There is 2 step in graceful shutdown. First OCS send terminate, then P-GW report used units in a final update + // There is 2 step in graceful shutdown. First OCS send terminate in Final-Unit-Indication, then P-GW report used units in a final update val updateRequest = client.createRequest( DEST_REALM, @@ -186,39 +278,40 @@ class OcsTest { session ) ?: fail("Failed to create request") - TestHelper.createUpdateRequestFinal(updateRequest.avps, "4333333333") + TestHelper.createUpdateRequestFinal(updateRequest.avps, msisdn, INITIAL_BALANCE, ratingGroup, serviceIdentifier) client.sendNextRequest(updateRequest, session) waitForAnswer() run { - assertEquals(2001L, client.resultCodeAvp?.integer32?.toLong()) + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp?.integer32?.toLong()) val resultAvps = client.resultAvps ?: fail("Missing AVPs") assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) assertEquals(RequestType.UPDATE_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) val resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) - assertEquals(2001L, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) - assertEquals(1, resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).integer32.toLong()) + assertEquals(DIAMETER_SUCCESS, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) + assertEquals(serviceIdentifier.toLong(), resultMSCC.grouped.getAvp(Avp.SERVICE_IDENTIFIER_CCA).integer32.toLong()) + assertEquals(ratingGroup.toLong(), resultMSCC.grouped.getAvp(Avp.RATING_GROUP).integer32.toLong()) val validTime = resultMSCC.grouped.getAvp(Avp.VALIDITY_TIME) assertEquals(86400L, validTime.unsigned32) } - // Last step is user disconnecting connection forcing a terminate + // Last step is P-GW sending CCR-Terminate val terminateRequest = client.createRequest( DEST_REALM, DEST_HOST, session ) ?: fail("Failed to create request") - TestHelper.createTerminateRequest(terminateRequest.avps, "4333333333") + TestHelper.createTerminateRequest(terminateRequest.avps, msisdn) client.sendNextRequest(terminateRequest, session) waitForAnswer() run { - assertEquals(2001L, client.resultCodeAvp?.integer32?.toLong()) + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp?.integer32?.toLong()) val resultAvps = client.resultAvps ?: fail("Missing AVPs") assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) @@ -227,6 +320,114 @@ class OcsTest { } + @Test + fun creditControlRequestInitUnknownUser() { + + val client = testClient ?: fail("Test client is null") + + val session = client.createSession() ?: fail("Failed to create session") + val request = client.createRequest( + DEST_REALM, + DEST_HOST, + session + ) ?: fail("Failed to create request") + + + // Requesting bucket for msisdn not in our system + TestHelper.createInitRequest(request.avps, "93682751", BUCKET_SIZE, 10, 1) + + client.sendNextRequest(request, session) + + waitForAnswer() + + run { + assertEquals(DIAMETER_USER_UNKNOWN, client.resultCodeAvp?.integer32?.toLong()) + val resultAvps = client.resultAvps ?: fail("Missing AVPs") + assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) + assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) + assertEquals(RequestType.INITIAL_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) + } + } + + @Test + fun creditControlRequestInitNoServiceId() { + + val email = "ocs-${randomInt()}@test.com" + createCustomer(name = "Test OCS User", email = email) + + val msisdn = createSubscription(email = email) + + val client = testClient ?: fail("Test client is null") + + val session = client.createSession() ?: fail("Failed to create session") + val request = client.createRequest( + DEST_REALM, + DEST_HOST, + session + ) ?: fail("Failed to create request") + + val ratingGroup = 10 + val serviceIdentifier = -1 + + TestHelper.createInitRequest(request.avps, msisdn, BUCKET_SIZE, ratingGroup, serviceIdentifier) + + client.sendNextRequest(request, session) + + waitForAnswer() + + run { + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp?.integer32?.toLong()) + val resultAvps = client.resultAvps ?: fail("Missing AVPs") + assertEquals(DEST_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).utF8String) + assertEquals(DEST_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).utF8String) + assertEquals(RequestType.INITIAL_REQUEST.toLong(), resultAvps.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) + val resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) + assertEquals(DIAMETER_SUCCESS, resultMSCC.grouped.getAvp(Avp.RESULT_CODE).integer32.toLong()) + assertEquals(ratingGroup.toLong(), resultMSCC.grouped.getAvp(Avp.RATING_GROUP).integer32.toLong()) + val granted = resultMSCC.grouped.getAvp(Avp.GRANTED_SERVICE_UNIT) + assertEquals(BUCKET_SIZE, granted.grouped.getAvp(Avp.CC_TOTAL_OCTETS).unsigned64) + } + } + + @Test + fun CreditControlRequestInitUpdateAndTerminateNoRequestedServiceUnit() { + + val email = "ocs-${randomInt()}@test.com" + createCustomer(name = "Test OCS User", email = email) + + val msisdn = createSubscription(email = email) + + val ratingGroup = 10 + val serviceIdentifier = -1 + + val client = testClient ?: fail("Test client is null") + + val session = client.createSession() ?: fail("Failed to create session") + + // This test assume that the default bucket size is set to 4000000L + simpleCreditControlRequestInit(session, msisdn,-1L, DEFAULT_REQUESTED_SERVICE_UNIT, ratingGroup, serviceIdentifier) + simpleCreditControlRequestUpdate(session, msisdn, -1L, DEFAULT_REQUESTED_SERVICE_UNIT, DEFAULT_REQUESTED_SERVICE_UNIT, ratingGroup, serviceIdentifier) + + val request = client.createRequest( + DEST_REALM, + DEST_HOST, + session + ) + + TestHelper.createTerminateRequest(request!!.avps, msisdn, DEFAULT_REQUESTED_SERVICE_UNIT, ratingGroup, serviceIdentifier) + + client.sendNextRequest(request, session) + + waitForAnswer() + + assertEquals(DIAMETER_SUCCESS, client.resultCodeAvp!!.integer32.toLong()) + val resultAvps = client.resultAvps + assertEquals(RequestType.TERMINATION_REQUEST.toLong(), resultAvps!!.getAvp(Avp.CC_REQUEST_TYPE).integer32.toLong()) + val resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) + assertEquals(DIAMETER_SUCCESS, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).integer32.toLong()) + } + + private fun waitForAnswer() { val client = testClient ?: fail("Test client is null") @@ -249,20 +450,11 @@ class OcsTest { private const val DEST_REALM = "loltel" private const val DEST_HOST = "ocs" - private const val INITIAL_BALANCE = 100_000_000L + private const val INITIAL_BALANCE = 2_147_483_648L private const val BUCKET_SIZE = 500L + private const val DEFAULT_REQUESTED_SERVICE_UNIT = 40_000_000L - private lateinit var EMAIL: String - private lateinit var MSISDN: String - - @BeforeClass - @JvmStatic - fun createTestUserAndSubscription() { - - EMAIL = "ocs-${randomInt()}@test.com" - createProfile(name = "Test OCS User", email = EMAIL) - - MSISDN = createSubscription(EMAIL) - } + private const val DIAMETER_SUCCESS = 2001L + private const val DIAMETER_USER_UNKNOWN = 5030L } } diff --git a/admin-api/build.gradle b/admin-api/build.gradle index 96f9fe6bd..b36e1d781 100644 --- a/admin-api/build.gradle +++ b/admin-api/build.gradle @@ -1,16 +1,13 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } dependencies { implementation project(":prime-modules") - implementation "javax.xml.bind:jaxb-api:$jaxbVersion" - implementation "javax.activation:activation:$javaxActivationVersion" - testImplementation project(":jersey") testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" } -apply from: '../jacoco.gradle' \ No newline at end of file +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/AdminModule.kt b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/AdminModule.kt index f8a72b1f3..0eaa8c795 100644 --- a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/AdminModule.kt +++ b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/AdminModule.kt @@ -16,5 +16,12 @@ class AdminModule : PrimeModule { jerseySever.register(ProductResource()) jerseySever.register(ProductClassResource()) jerseySever.register(ImporterResource(ImportAdapter())) + jerseySever.register(ProfilesResource()) + jerseySever.register(BundlesResource()) + jerseySever.register(PurchaseResource()) + jerseySever.register(RefundResource()) + jerseySever.register(NotifyResource()) + jerseySever.register(PlanResource()) + jerseySever.register(KYCResource()) } } diff --git a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/HoustonResources.kt b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/HoustonResources.kt new file mode 100644 index 000000000..e16dce4da --- /dev/null +++ b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/HoustonResources.kt @@ -0,0 +1,495 @@ +package org.ostelco.prime.admin.api + +import arrow.core.Either +import io.dropwizard.auth.Auth +import org.ostelco.prime.apierror.ApiError +import org.ostelco.prime.apierror.ApiErrorCode +import org.ostelco.prime.apierror.ApiErrorMapper +import org.ostelco.prime.apierror.InternalServerError +import org.ostelco.prime.apierror.NotFoundError +import org.ostelco.prime.appnotifier.AppNotifier +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.getLogger +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.Plan +import org.ostelco.prime.model.PurchaseRecord +import org.ostelco.prime.model.ScanInformation +import org.ostelco.prime.model.Subscription +import org.ostelco.prime.module.getResource +import org.ostelco.prime.notifications.NOTIFY_OPS_MARKER +import org.ostelco.prime.paymentprocessor.core.ForbiddenError +import org.ostelco.prime.paymentprocessor.core.ProductInfo +import org.ostelco.prime.storage.AdminDataSource +import java.net.URLDecoder +import java.util.regex.Pattern +import javax.validation.constraints.NotNull +import javax.ws.rs.Consumes +import javax.ws.rs.DELETE +import javax.ws.rs.GET +import javax.ws.rs.POST +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.PathParam +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * Resource used to handle the profile related REST calls. + */ +@Path("/profiles") +class ProfilesResource { + private val logger by getLogger() + private val storage by lazy { getResource() } + + /** + * Get the subscriber profile. + */ + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + fun getProfile(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("id") + id: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + val decodedId = URLDecoder.decode(id, "UTF-8") + return if (!isEmail(decodedId)) { + logger.info("${token.name} Accessing profile for msisdn:$decodedId") + getProfileForMsisdn(decodedId).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } else { + logger.info("${token.name} Accessing profile for email:$decodedId") + getProfile(Identity(decodedId, "EMAIL", "email")).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + } + + /** + * Get the subscriptions for this subscriber. + */ + @GET + @Path("{email}/subscriptions") + @Produces(MediaType.APPLICATION_JSON) + fun getSubscriptions(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("email") + email: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + val decodedId = URLDecoder.decode(email, "UTF-8") + logger.info("${token.name} Accessing subscriptions for email:$decodedId") + return getSubscriptions(Identity(decodedId, "EMAIL", "email")).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + /** + * Get all the eKYC scan information for this subscriber. + */ + @GET + @Path("{email}/scans") + @Produces(MediaType.APPLICATION_JSON) + fun getAllScanInformation(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("email") + email: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + val decodedId = URLDecoder.decode(email, "UTF-8") + logger.info("${token.name} Accessing scan information for email:$decodedId") + return getAllScanInformation(identity = Identity(id = decodedId, type = "EMAIL", provider = "email")).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + private fun getAllScanInformation(identity: Identity): Either> { + return try { + storage.getAllScanInformation(identity = identity).mapLeft { + NotFoundError("Failed to fetch scan information.", ApiErrorCode.FAILED_TO_FETCH_SCAN_INFORMATION, it) + } + } catch (e: Exception) { + logger.error("Failed to fetch scan information for customer with identity - $identity", e) + Either.left(NotFoundError("Failed to fetch scan information", ApiErrorCode.FAILED_TO_FETCH_SCAN_INFORMATION)) + } + } + + // TODO: Reuse the one from SubscriberDAO + private fun getProfile(identity: Identity): Either { + return try { + storage.getCustomer(identity).mapLeft { + NotFoundError("Failed to fetch profile.", ApiErrorCode.FAILED_TO_FETCH_CUSTOMER, it) + } + } catch (e: Exception) { + logger.error("Failed to fetch profile for customer with identity - $identity", e) + Either.left(NotFoundError("Failed to fetch profile", ApiErrorCode.FAILED_TO_FETCH_CUSTOMER)) + } + } + + private fun isEmail(email: String): Boolean { + val regex = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$" + val pattern = Pattern.compile(regex) + return pattern.matcher(email).matches() + } + + private fun getProfileForMsisdn(msisdn: String): Either { + return try { + storage.getCustomerForMsisdn(msisdn).mapLeft { + NotFoundError("Failed to fetch profile.", ApiErrorCode.FAILED_TO_FETCH_CUSTOMER, it) + } + } catch (e: Exception) { + logger.error("Failed to fetch profile for msisdn $msisdn", e) + Either.left(NotFoundError("Failed to fetch profile", ApiErrorCode.FAILED_TO_FETCH_CUSTOMER)) + } + } + + // TODO: Reuse the one from SubscriberDAO + private fun getSubscriptions(identity: Identity): Either> { + return try { + storage.getSubscriptions(identity).mapLeft { + NotFoundError("Failed to get subscriptions.", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS, it) + } + } catch (e: Exception) { + logger.error("Failed to get subscriptions for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get subscriptions", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS)) + } + } + + /** + * Fetches and return all plans that a subscriber subscribes + * to if any. + */ + @GET + @Path("{email}/plans") + @Produces("application/json") + fun getPlans(@PathParam("email") email: String): Response { + return storage.getPlans(identity = Identity(id = email, type = "EMAIL", provider = "email")).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to fetch plans", + ApiErrorCode.FAILED_TO_FETCH_PLANS_FOR_SUBSCRIBER, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + } + + /** + * Attaches (subscribes) a subscriber to a plan. + */ + @POST + @Path("{email}/plans/{planId}") + @Produces("application/json") + fun attachPlan(@PathParam("email") email: String, + @PathParam("planId") planId: String, + @QueryParam("trial_end") trialEnd: Long): Response { + return storage.subscribeToPlan( + identity = Identity(id = email, type = "EMAIL", provider = "email"), + planId = planId, + trialEnd = trialEnd).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to store subscription", + ApiErrorCode.FAILED_TO_STORE_SUBSCRIPTION, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.CREATED) } + ).build() + } + + /** + * Removes a plan from the list subscriptions for a subscriber. + */ + @DELETE + @Path("{email}/plans/{planId}") + @Produces("application/json") + fun detachPlan(@PathParam("email") email: String, + @PathParam("planId") planId: String): Response { + return storage.unsubscribeFromPlan( + identity = Identity(id = email, type = "EMAIL", provider = "email"), + planId = planId).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to remove subscription", + ApiErrorCode.FAILED_TO_REMOVE_SUBSCRIPTION, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK) } + ).build() + } +} + +/** + * Resource used to handle bundles related REST calls. + */ +@Path("/bundles") +class BundlesResource { + private val logger by getLogger() + private val storage by lazy { getResource() } + + /** + * Get all bundles for the subscriber. + */ + @GET + @Path("{email}") + @Produces(MediaType.APPLICATION_JSON) + fun getBundlesByEmail(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("email") + email: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + val decodedEmail = URLDecoder.decode(email, "UTF-8") + logger.info("${token.name} Accessing bundles for $decodedEmail") + return getBundles(Identity(decodedEmail, "EMAIL", "email")).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + // TODO: Reuse the one from SubscriberDAO + private fun getBundles(identity: Identity): Either> { + return try { + storage.getBundles(identity).mapLeft { + NotFoundError("Failed to get bundles. ${it.message}", ApiErrorCode.FAILED_TO_FETCH_BUNDLES) + } + } catch (e: Exception) { + logger.error("Failed to get bundles for customer with identity - $identity", e) + Either.left(NotFoundError("Failed to get bundles", ApiErrorCode.FAILED_TO_FETCH_BUNDLES)) + } + } +} + +/** + * Resource used to handle purchase related REST calls. + */ +@Path("/purchases") +class PurchaseResource { + private val logger by getLogger() + private val storage by lazy { getResource() } + + /** + * Get all purchase history for the subscriber. + */ + @GET + @Path("{email}") + @Produces(MediaType.APPLICATION_JSON) + fun getPurchaseHistoryByEmail(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("email") + email: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + val decodedEmail = URLDecoder.decode(email, "UTF-8") + logger.info("${token.name} Accessing bundles for $decodedEmail") + return getPurchaseHistory(Identity(decodedEmail, "EMAIL", "email")).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + // TODO: Reuse the one from SubscriberDAO + private fun getPurchaseHistory(identity: Identity): Either> { + return try { + return storage.getPurchaseRecords(identity).bimap( + { NotFoundError("Failed to get purchase history.", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_HISTORY, it) }, + { it.toList() }) + } catch (e: Exception) { + logger.error("Failed to get purchase history for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get purchase history", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_HISTORY)) + } + } +} + +/** + * Resource used to handle refund related REST calls. + */ +@Path("/refund") +class RefundResource { + private val logger by getLogger() + private val storage by lazy { getResource() } + + /** + * Refund a specified purchase for the subscriber. + */ + @PUT + @Path("{email}") + @Produces(MediaType.APPLICATION_JSON) + fun refundPurchaseByEmail(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("email") + email: String, + @NotNull + @QueryParam("purchaseRecordId") + purchaseRecordId: String, + @NotNull + @QueryParam("reason") + reason: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + val decodedEmail = URLDecoder.decode(email, "UTF-8") + logger.info("${token.name} Refunding purchase for $decodedEmail at id: $purchaseRecordId") + return refundPurchase(Identity(decodedEmail, "EMAIL", "email"), purchaseRecordId, reason).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { + logger.info(NOTIFY_OPS_MARKER, "${token.name} refunded the purchase (id:$purchaseRecordId) for $decodedEmail ") + Response.status(Response.Status.OK).entity(asJson(it)) + }) + .build() + } + + private fun refundPurchase(identity: Identity, purchaseRecordId: String, reason: String): Either { + return try { + return storage.refundPurchase(identity, purchaseRecordId, reason).mapLeft { + when (it) { + is ForbiddenError -> org.ostelco.prime.apierror.ForbiddenError("Failed to refund purchase. ${it.description}", ApiErrorCode.FAILED_TO_REFUND_PURCHASE) + else -> NotFoundError("Failed to refund purchase. ${it.description}", ApiErrorCode.FAILED_TO_REFUND_PURCHASE) + } + } + } catch (e: Exception) { + logger.error("Failed to refund purchase for customer with identity - $identity, id: $purchaseRecordId", e) + Either.left(InternalServerError("Failed to refund purchase", ApiErrorCode.FAILED_TO_REFUND_PURCHASE)) + } + } +} + +/** + * Resource used to handle notification related REST calls. + */ +@Path("/notify") +class NotifyResource { + private val logger by getLogger() + private val storage by lazy { getResource() } + private val notifier by lazy { getResource() } + /** + * Sends a notification to all devices for a subscriber. + */ + @PUT + @Path("{email}") + @Produces(MediaType.APPLICATION_JSON) + fun sendNotificationByEmail(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("email") + email: String, + @NotNull + @QueryParam("title") + title: String, + @NotNull + @QueryParam("message") + message: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + val decodedEmail = URLDecoder.decode(email, "UTF-8") + return getCustomerId(email = decodedEmail).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { customerId -> + logger.info("${token.name} Sending notification to $decodedEmail customerId: $customerId") + notifier.notify(customerId, title, message) + Response.status(Response.Status.OK).entity("Message Sent") + }) + .build() + + } + + private fun getCustomerId(email: String): Either { + return try { + storage.getCustomerId(identity = Identity(id = email, type = "EMAIL", provider = "email")).mapLeft { + NotFoundError("Did not find msisdn for this subscription.", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS, it) + } + } catch (e: Exception) { + logger.error("Did not find msisdn for email $email", e) + Either.left(InternalServerError("Did not find subscription", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS)) + } + } +} + +/** + * Resource used to handle plans related REST calls. + */ +@Path("/plans") +class PlanResource { + + private val storage by lazy { getResource() } + + /** + * Return plan details. + */ + @GET + @Path("{planId}") + @Produces("application/json") + fun get(@NotNull + @PathParam("planId") planId: String): Response { + return storage.getPlan(planId).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to fetch plan", + ApiErrorCode.FAILED_TO_FETCH_PLAN, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + } + + /** + * Creates a plan. + */ + @POST + @Produces("application/json") + @Consumes("application/json") + fun create(plan: Plan): Response { + return storage.createPlan(plan).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to store plan", + ApiErrorCode.FAILED_TO_STORE_PLAN, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.CREATED).entity(asJson(it)) } + ).build() + } + + /** + * Deletes a plan. + * Note, will fail if there are subscriptions on the plan. + */ + @DELETE + @Path("{planId}") + @Produces("application/json") + fun delete(@NotNull + @PathParam("planId") planId: String): Response { + return storage.deletePlan(planId).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to remove plan", + ApiErrorCode.FAILED_TO_REMOVE_PLAN, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + } +} diff --git a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/KYCResource.kt b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/KYCResource.kt new file mode 100644 index 000000000..b08f8535d --- /dev/null +++ b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/KYCResource.kt @@ -0,0 +1,195 @@ +package org.ostelco.prime.admin.api + +import arrow.core.Either +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.ostelco.prime.apierror.ApiError +import org.ostelco.prime.apierror.ApiErrorCode +import org.ostelco.prime.apierror.InternalServerError +import org.ostelco.prime.apierror.BadRequestError +import org.ostelco.prime.apierror.NotFoundError +import org.ostelco.prime.getLogger +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.JumioScanData +import org.ostelco.prime.model.ScanInformation +import org.ostelco.prime.model.ScanResult +import org.ostelco.prime.model.ScanStatus +import org.ostelco.prime.module.getResource +import org.ostelco.prime.storage.AdminDataSource +import java.io.IOException +import java.time.Instant +import java.util.* +import javax.servlet.http.HttpServletRequest +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.Context +import javax.ws.rs.core.HttpHeaders +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.MultivaluedMap +import javax.ws.rs.core.Response + + +/** + * Resource used to handle the eKYC related REST calls. + */ +@Path("/ekyc/callback") +class KYCResource { + private val logger by getLogger() + private val storage by lazy { getResource() } + + private fun toRegularMap(m: MultivaluedMap?): Map { + val map = HashMap() + if (m == null) { + return map + } + for (entry in m.entries) { + val sb = StringBuilder() + for (s in entry.value) { + if (sb.length > 0) { + sb.append(',') + } + sb.append(s) + } + map[entry.key] = sb.toString() + } + return map + } + + private fun toScanStatus(status: String): ScanStatus { + return when (status) { + "SUCCESS" -> { ScanStatus.APPROVED } + else -> { ScanStatus.REJECTED } + } + } + + private fun toRegularMap(jsonData: String?): Map? { + try { + if (jsonData != null) { + return ObjectMapper().readValue(jsonData) + } + } catch (e: IOException) { + logger.error("Cannot parse Json Data: $jsonData") + } + return null + } + + private fun toScanInformation(dataMap: Map): ScanInformation? { + try { + val vendorScanReference: String = dataMap[JumioScanData.JUMIO_SCAN_ID.s]!! + var status: ScanStatus = toScanStatus(dataMap[JumioScanData.SCAN_STATUS.s]!!) + val verificationStatus: String = dataMap[JumioScanData.VERIFICATION_STATUS.s]!! + val time: Long = Instant.parse(dataMap[JumioScanData.CALLBACK_DATE.s]!!).toEpochMilli() + val type: String? = dataMap[JumioScanData.ID_TYPE.s] + val country: String? = dataMap[JumioScanData.ID_COUNTRY.s] + val firstName: String? = dataMap[JumioScanData.ID_FIRSTNAME.s] + val lastName: String? = dataMap[JumioScanData.ID_LASTNAME.s] + val dob: String? = dataMap[JumioScanData.ID_DOB.s] + var rejectReason: String? = dataMap[JumioScanData.REJECT_REASON.s] + val scanId: String = dataMap[JumioScanData.SCAN_ID.s]!! + val identityVerificationData: String? = dataMap[JumioScanData.IDENTITY_VERIFICATION.s] + + // Check if the id matched with the photo. + if (verificationStatus.toUpperCase() == JumioScanData.APPROVED_VERIFIED.s) { + val identityVerification = toRegularMap(identityVerificationData) + if (identityVerification == null) { + // something gone wrong while parsing identityVerification + rejectReason = """{ "message": "Missing ${JumioScanData.IDENTITY_VERIFICATION.s} information" }""" + status = ScanStatus.REJECTED + } else { + // identityVerification field is present + val similarity = identityVerification[JumioScanData.SIMILARITY.s] + val validity = identityVerification[JumioScanData.VALIDITY.s] + if (!(similarity != null && similarity.toUpperCase() == JumioScanData.MATCH.s && + validity != null && validity.toUpperCase() == JumioScanData.TRUE.s)) { + status = ScanStatus.REJECTED + rejectReason = identityVerificationData + } + } + } + val countryCode = getCountryCodeForScan(scanId) + if (countryCode != null) { + return ScanInformation(scanId, countryCode, status, ScanResult( + vendorScanReference = vendorScanReference, + verificationStatus = verificationStatus, + time = time, + type = type, + country = country, + firstName = firstName, + lastName = lastName, + dob = dob, + rejectReason = rejectReason + )) + } else { + return null + } + } + catch (e: NullPointerException) { + logger.error("Missing mandatory fields in scan result $dataMap", e) + return null + } + } + + @POST + @Produces(MediaType.APPLICATION_JSON) + fun handleCallback( + @Context request: HttpServletRequest, + @Context httpHeaders: HttpHeaders, + formData: MultivaluedMap): Response { + dumpRequestInfo(request, httpHeaders, formData) + val scanInformation = toScanInformation(toRegularMap(formData)) + if (scanInformation == null) { + logger.info("Unable to convert scan information from form data") + val reqError = BadRequestError("Missing mandatory fields in scan result", ApiErrorCode.FAILED_TO_UPDATE_SCAN_RESULTS) + return Response.status(reqError.status).entity(asJson(reqError)).build() + } + logger.info("Updating scan information ${scanInformation.scanId} jumioIdScanReference ${scanInformation.scanResult?.vendorScanReference}") + return updateScanInformation(scanInformation, formData).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(scanInformation)) }).build() + } + + private fun getCountryCodeForScan(scanId: String): String? { + return try { + storage.getCountryCodeForScan(scanId).fold({ + logger.error("Failed to get country code for scan $scanId") + null + }, { + it + }) + } catch (e: Exception) { + logger.error("Caught error while getting country code for scan $scanId") + return null + } + } + + private fun updateScanInformation(scanInformation: ScanInformation, formData: MultivaluedMap): Either { + return try { + return storage.updateScanInformation(scanInformation, formData).mapLeft { + logger.error("Failed to update scan information ${scanInformation.scanId} jumioIdScanReference ${scanInformation.scanResult?.vendorScanReference}") + NotFoundError("Failed to update scan information. ${it.message}", ApiErrorCode.FAILED_TO_UPDATE_SCAN_RESULTS) + } + } catch (e: Exception) { + logger.error("Caught error while updating scan information ${scanInformation.scanId} jumioIdScanReference ${scanInformation.scanResult?.vendorScanReference}", e) + Either.left(InternalServerError("Failed to update scan information", ApiErrorCode.FAILED_TO_UPDATE_SCAN_RESULTS)) + } + } + //TODO: Prasanth, remove this method after testing + private fun dumpRequestInfo(request: HttpServletRequest, httpHeaders: HttpHeaders, formData: MultivaluedMap): String { + var result = "" +// result += "Address: ${request.remoteHost} (${request.remoteAddr} : ${request.remotePort}) \n" +// result += "Query: ${request.queryString} \n" +// result += "Headers == > \n" +// val requestHeaders = httpHeaders.getRequestHeaders() +// for (entry in requestHeaders.entries) { +// result += "${entry.key} = ${entry.value}\n" +// } + result += "\nRequest Data: \n" + for (entry in formData.entries) { + result += "${entry.key} = ${entry.value}\n" + } + logger.info(result) + + return result + } +} diff --git a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/Resources.kt b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/Resources.kt index 04714845c..3af93da93 100644 --- a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/Resources.kt +++ b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/Resources.kt @@ -1,6 +1,7 @@ package org.ostelco.prime.admin.api +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.Offer import org.ostelco.prime.model.Product import org.ostelco.prime.model.ProductClass @@ -15,6 +16,7 @@ import javax.ws.rs.PathParam import javax.ws.rs.QueryParam import javax.ws.rs.core.Response +@Deprecated(message = "Assigning MSISDN to Customer via Admin API will be removed in future.") @Path("/admin/subscriptions") class SubscriptionsResource { @@ -22,10 +24,10 @@ class SubscriptionsResource { @POST fun createSubscription( - @QueryParam("subscription_id") subscriberId: String, + @QueryParam("subscription_id") email: String, @QueryParam("msisdn") msisdn: String): Response { - return adminDataSource.addSubscription(subscriberId, msisdn) + return adminDataSource.addSubscription(Identity(email, "EMAIL", "email"), msisdn) .fold({ Response.status(Response.Status.NOT_FOUND).entity(it.message).build() }, { Response.status(Response.Status.CREATED).build() }) } diff --git a/admin-api/src/test/kotlin/org/ostelco/importer/ImporterResourceTest.kt b/admin-api/src/test/kotlin/org/ostelco/importer/ImporterResourceTest.kt index 550f9041d..b59ed7eb9 100644 --- a/admin-api/src/test/kotlin/org/ostelco/importer/ImporterResourceTest.kt +++ b/admin-api/src/test/kotlin/org/ostelco/importer/ImporterResourceTest.kt @@ -1,6 +1,7 @@ package org.ostelco.importer import arrow.core.Either +import arrow.core.right import io.dropwizard.testing.FixtureHelpers.fixture import io.dropwizard.testing.junit.ResourceTestRule import org.junit.Assert.assertEquals @@ -33,29 +34,19 @@ class ImporterResourceTest { private val processor: ImportProcessor = object : ImportProcessor { override fun createOffer(createOffer: CreateOffer): Either { - Companion.offer = createOffer.createOffer - return Either.right(Unit) + offer = createOffer.createOffer + return Unit.right() } - override fun createSegments(createSegments: CreateSegments): Either { - return Either.right(Unit) - } + override fun createSegments(createSegments: CreateSegments): Either = Unit.right() - override fun updateSegments(updateSegments: UpdateSegments): Either { - return Either.right(Unit) - } + override fun updateSegments(updateSegments: UpdateSegments): Either = Unit.right() - override fun addToSegments(addToSegments: AddToSegments): Either { - return Either.right(Unit) - } + override fun addToSegments(addToSegments: AddToSegments): Either = Unit.right() - override fun removeFromSegments(removeFromSegments: RemoveFromSegments): Either { - return Either.right(Unit) - } + override fun removeFromSegments(removeFromSegments: RemoveFromSegments): Either = Unit.right() - override fun changeSegments(changeSegments: ChangeSegments): Either { - return Either.right(Unit) - } + override fun changeSegments(changeSegments: ChangeSegments): Either = Unit.right() } @ClassRule diff --git a/analytics-grpc-api/build.gradle b/analytics-grpc-api/build.gradle index 1fb4487e6..ae47cd968 100644 --- a/analytics-grpc-api/build.gradle +++ b/analytics-grpc-api/build.gradle @@ -1,6 +1,6 @@ plugins { id "java-library" - id "com.google.protobuf" version "0.8.6" + id "com.google.protobuf" version "0.8.8" id "idea" } @@ -18,7 +18,7 @@ protobuf { artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" } } - protoc { artifact = 'com.google.protobuf:protoc:3.6.1' } + protoc { artifact = "com.google.protobuf:protoc:$protocVersion" } generateProtoTasks { all()*.plugins { grpc {} diff --git a/analytics-module/build.gradle b/analytics-module/build.gradle index 7864df2e3..2677e2a4f 100644 --- a/analytics-module/build.gradle +++ b/analytics-module/build.gradle @@ -1,21 +1,13 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } dependencies { implementation project(":prime-modules") - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" implementation "com.google.cloud:google-cloud-pubsub:$googleCloudVersion" implementation 'com.google.code.gson:gson:2.8.5' - testImplementation 'com.google.api:gax-grpc:1.33.1' - - testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" - testImplementation "org.mockito:mockito-core:$mockitoVersion" - testImplementation "org.assertj:assertj-core:$assertJVersion" + testImplementation 'com.google.api:gax-grpc:1.43.0' } - -apply from: '../jacoco.gradle' diff --git a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsGrpcService.kt b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsGrpcService.kt index bf63afd51..d7f4f84b4 100644 --- a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsGrpcService.kt +++ b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsGrpcService.kt @@ -13,11 +13,11 @@ import java.util.* /** - * Serves incoming GRPC analytics requests. + * Serves incoming gRPC analytics requests. * * It's implemented as a subclass of [OcsServiceGrpc.OcsServiceImplBase] overriding * methods that together implements the protocol described in the analytics protobuf - * file: ocs_analytics.proto + * file: analytics.proto *` * service OcsgwAnalyticsService { * rpc OcsgwAnalyticsEvent (stream OcsgwAnalyticsReport) returns (OcsgwAnalyticsReply) {} diff --git a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsModule.kt b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsModule.kt index 0b8e35dec..0eb5e21dc 100644 --- a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsModule.kt +++ b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsModule.kt @@ -3,7 +3,7 @@ package org.ostelco.prime.analytics import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonTypeName import io.dropwizard.setup.Environment -import org.hibernate.validator.constraints.NotEmpty +import org.hibernate.validator.constraints.NotBlank import org.ostelco.prime.analytics.metrics.CustomMetricsRegistry import org.ostelco.prime.analytics.publishers.ActiveUsersPublisher import org.ostelco.prime.analytics.publishers.DataConsumptionInfoPublisher @@ -33,23 +33,22 @@ class AnalyticsModule : PrimeModule { } } -class AnalyticsConfig { - @NotEmpty +data class AnalyticsConfig( + @NotBlank @JsonProperty("projectId") - lateinit var projectId: String + val projectId: String, - @NotEmpty + @NotBlank @JsonProperty("dataTrafficTopicId") - lateinit var dataTrafficTopicId: String + val dataTrafficTopicId: String, - @NotEmpty + @NotBlank @JsonProperty("purchaseInfoTopicId") - lateinit var purchaseInfoTopicId: String + val purchaseInfoTopicId: String, - @NotEmpty + @NotBlank @JsonProperty("activeUsersTopicId") - lateinit var activeUsersTopicId: String -} + val activeUsersTopicId: String) object ConfigRegistry { lateinit var config: AnalyticsConfig diff --git a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsServiceImpl.kt b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsServiceImpl.kt index 453b77c5f..5bdf34928 100644 --- a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsServiceImpl.kt +++ b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsServiceImpl.kt @@ -10,16 +10,16 @@ class AnalyticsServiceImpl : AnalyticsService { private val logger by getLogger() - override fun reportTrafficInfo(msisdn: String, usedBytes: Long, bundleBytes: Long, apn: String?, mccMnc: String?) { - logger.info("reportTrafficInfo : msisdn {} usedBytes {} bundleBytes {} apn {} mccMnc {}", msisdn, usedBytes, bundleBytes, apn, mccMnc) - DataConsumptionInfoPublisher.publish(msisdn, usedBytes, bundleBytes, apn, mccMnc) + override fun reportTrafficInfo(msisdnAnalyticsId: String, usedBytes: Long, bundleBytes: Long, apn: String?, mccMnc: String?) { + logger.info("reportTrafficInfo : msisdnAnalyticsId {} usedBytes {} bundleBytes {} apn {} mccMnc {}", msisdnAnalyticsId, usedBytes, bundleBytes, apn, mccMnc) + DataConsumptionInfoPublisher.publish(msisdnAnalyticsId, usedBytes, bundleBytes, apn, mccMnc) } override fun reportMetric(primeMetric: PrimeMetric, value: Long) { CustomMetricsRegistry.updateMetricValue(primeMetric, value) } - override fun reportPurchaseInfo(purchaseRecord: PurchaseRecord, subscriberId: String, status: String) { - PurchaseInfoPublisher.publish(purchaseRecord, subscriberId, status) + override fun reportPurchaseInfo(purchaseRecord: PurchaseRecord, customerAnalyticsId: String, status: String) { + PurchaseInfoPublisher.publish(purchaseRecord, customerAnalyticsId, status) } } \ No newline at end of file diff --git a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/ActiveUsersPublisher.kt b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/ActiveUsersPublisher.kt index 204af1015..3eb84f7b7 100644 --- a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/ActiveUsersPublisher.kt +++ b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/ActiveUsersPublisher.kt @@ -11,8 +11,6 @@ import org.ostelco.analytics.api.ActiveUsersInfo import org.ostelco.prime.analytics.ConfigRegistry import org.ostelco.prime.getLogger import org.ostelco.prime.metrics.api.User -import org.ostelco.prime.module.getResource -import org.ostelco.prime.pseudonymizer.PseudonymizerService import java.time.Instant /** @@ -23,7 +21,6 @@ object ActiveUsersPublisher : private val logger by getLogger() - private val pseudonymizerService by lazy { getResource() } private val jsonPrinter = JsonFormat.printer().includingDefaultValueFields() private fun convertToJson(activeUsersInfo: ActiveUsersInfo): ByteString = @@ -34,8 +31,7 @@ object ActiveUsersPublisher : val activeUsersInfoBuilder = ActiveUsersInfo.newBuilder().setTimestamp(Timestamps.fromMillis(timestamp)) for (user in userList) { val userBuilder = org.ostelco.analytics.api.User.newBuilder() - val pseudonym = pseudonymizerService.getMsisdnPseudonym(user.msisdn, timestamp).pseudonym - activeUsersInfoBuilder.addUsers(userBuilder.setApn(user.apn).setMccMnc(user.mccMnc).setMsisdn(pseudonym).build()) + activeUsersInfoBuilder.addUsers(userBuilder.setApn(user.apn).setMccMnc(user.mccMnc).setMsisdn(user.msisdn).build()) } val pubsubMessage = PubsubMessage.newBuilder() diff --git a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DataConsumptionInfoPublisher.kt b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DataConsumptionInfoPublisher.kt index 954f764b9..8f4bc9a2b 100644 --- a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DataConsumptionInfoPublisher.kt +++ b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DataConsumptionInfoPublisher.kt @@ -8,8 +8,6 @@ import com.google.pubsub.v1.PubsubMessage import org.ostelco.analytics.api.DataTrafficInfo import org.ostelco.prime.analytics.ConfigRegistry import org.ostelco.prime.getLogger -import org.ostelco.prime.module.getResource -import org.ostelco.prime.pseudonymizer.PseudonymizerService import java.time.Instant /** @@ -20,19 +18,16 @@ object DataConsumptionInfoPublisher : private val logger by getLogger() - private val pseudonymizerService by lazy { getResource() } - - fun publish(msisdn: String, usedBucketBytes: Long, bundleBytes: Long, apn: String?, mccMnc: String?) { + fun publish(msisdnAnalyticsId: String, usedBucketBytes: Long, bundleBytes: Long, apn: String?, mccMnc: String?) { if (usedBucketBytes == 0L) { return } val now = Instant.now().toEpochMilli() - val pseudonym = pseudonymizerService.getMsisdnPseudonym(msisdn, now).pseudonym val data = DataTrafficInfo.newBuilder() - .setMsisdn(pseudonym) + .setMsisdn(msisdnAnalyticsId) .setBucketBytes(usedBucketBytes) .setBundleBytes(bundleBytes) .setTimestamp(Timestamps.fromMillis(now)) @@ -57,7 +52,7 @@ object DataConsumptionInfoPublisher : logger.warn("Status code: {}", throwable.statusCode.code) logger.warn("Retrying: {}", throwable.isRetryable) } - logger.warn("Error publishing message for msisdn: {}", msisdn) + logger.warn("Error publishing message for msisdnAnalyticsId: {}", msisdnAnalyticsId) } override fun onSuccess(messageId: String) { diff --git a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DelegatePubSubPublisher.kt b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DelegatePubSubPublisher.kt index 39ce4592c..2dde59bb4 100644 --- a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DelegatePubSubPublisher.kt +++ b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/DelegatePubSubPublisher.kt @@ -33,7 +33,7 @@ class DelegatePubSubPublisher( Publisher.newBuilder(topicName) .setChannelProvider(channelProvider) .setCredentialsProvider(NoCredentialsProvider()) - .build(); + .build() } else { Publisher.newBuilder(topicName).build() } diff --git a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/PurchaseInfoPublisher.kt b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/PurchaseInfoPublisher.kt index 8913ab360..8a4349e27 100644 --- a/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/PurchaseInfoPublisher.kt +++ b/analytics-module/src/main/kotlin/org/ostelco/prime/analytics/publishers/PurchaseInfoPublisher.kt @@ -15,9 +15,6 @@ import org.ostelco.prime.analytics.ConfigRegistry import org.ostelco.prime.getLogger import org.ostelco.prime.model.PurchaseRecord import org.ostelco.prime.model.PurchaseRecordInfo -import org.ostelco.prime.module.getResource -import org.ostelco.prime.pseudonymizer.PseudonymizerService -import java.net.URLEncoder /** @@ -28,8 +25,6 @@ object PurchaseInfoPublisher : private val logger by getLogger() - private val pseudonymizerService by lazy { getResource() } - private var gson: Gson = createGson() private fun createGson(): Gson { @@ -57,13 +52,10 @@ object PurchaseInfoPublisher : ByteString.copyFromUtf8(gson.toJson(purchaseRecordInfo)) - fun publish(purchaseRecord: PurchaseRecord, subscriberId: String, status: String) { - - val encodedSubscriberId = URLEncoder.encode(subscriberId, "UTF-8") - val pseudonym = pseudonymizerService.getSubscriberIdPseudonym(encodedSubscriberId, purchaseRecord.timestamp).pseudonym + fun publish(purchaseRecord: PurchaseRecord, customerId: String, status: String) { val pubsubMessage = PubsubMessage.newBuilder() - .setData(convertToJson(PurchaseRecordInfo(purchaseRecord, pseudonym, status))) + .setData(convertToJson(PurchaseRecordInfo(purchaseRecord, customerId, status))) .build() //schedule a message to be published, messages are automatically batched @@ -78,7 +70,7 @@ object PurchaseInfoPublisher : logger.warn("Status code: {}", throwable.statusCode.code) logger.warn("Retrying: {}", throwable.isRetryable) } - logger.warn("Error publishing purchase record for msisdn: {}", purchaseRecord.msisdn) + logger.warn("Error publishing purchase record for customerId: {}", customerId) } override fun onSuccess(messageId: String) { diff --git a/app-notifier/build.gradle b/app-notifier/build.gradle index 576935bca..e6f185950 100644 --- a/app-notifier/build.gradle +++ b/app-notifier/build.gradle @@ -1,12 +1,9 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } dependencies { implementation project(":prime-modules") implementation project(":firebase-extensions") - - testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" } \ No newline at end of file diff --git a/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt b/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt index a83f3251a..c21431c81 100644 --- a/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt +++ b/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt @@ -2,14 +2,17 @@ package org.ostelco.prime.appnotifier import com.google.api.core.ApiFutureCallback import com.google.api.core.ApiFutures.addCallback +import com.google.common.util.concurrent.MoreExecutors.directExecutor import com.google.firebase.FirebaseApp import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.Message import com.google.firebase.messaging.Notification +import org.ostelco.prime.getLogger import org.ostelco.prime.module.getResource import org.ostelco.prime.storage.ClientDataSource class FirebaseAppNotifier: AppNotifier { + private val logger by getLogger() val listOfFailureCodes = listOf( "messaging/invalid-recipient", @@ -17,26 +20,34 @@ class FirebaseAppNotifier: AppNotifier { "messaging/registration-token-not-registered" ) - override fun notify(msisdn: String, title: String, body: String) { - println("Will try to notify msisdn : $msisdn") - sendNotification(msisdn, title, body) + override fun notify(customerId: String, title: String, body: String) { + logger.info("Will try to notify customer with Id : $customerId") + sendNotification(customerId, title, body, data = null) } - private fun sendNotification(msisdn: String, title: String, body: String) { + override fun notify(customerId: String, title: String, body: String, data: Map) { + logger.info("Will try to notify-with-data customer with Id : $customerId") + sendNotification(customerId, title, body, data) + } + + private fun sendNotification(customerId: String, title: String, body: String, data: Map?) { val store = getResource() // This registration token comes from the client FCM SDKs. - val applicationTokens = store.getNotificationTokens(msisdn) + val applicationTokens = store.getNotificationTokens(customerId) for (applicationToken in applicationTokens) { if (applicationToken.tokenType == "FCM") { // See documentation on defining a message payload. - val message = Message.builder() + val builder = Message.builder() .setNotification(Notification(title, body)) .setToken(applicationToken.token) - .build() + if (data != null) { + builder.putAllData(data) + } + val message = builder.build() // Send a message to the device corresponding to the provided // registration token. @@ -46,17 +57,17 @@ class FirebaseAppNotifier: AppNotifier { val apiFutureCallback = object : ApiFutureCallback { override fun onSuccess(result: String) { - println("Notification completed with result: $result") + logger.info("Notification completed with result: $result") if (listOfFailureCodes.contains(result)) { - store.removeNotificationToken(msisdn, applicationToken.applicationID) + store.removeNotificationToken(customerId, applicationToken.applicationID) } } override fun onFailure(t: Throwable) { - println("Notification failed with error: $t") + logger.warn("Notification failed with error: $t") } } - addCallback(future, apiFutureCallback) + addCallback(future, apiFutureCallback, directExecutor()) } } } diff --git a/auth-server/Dockerfile b/auth-server/Dockerfile index 99c77878a..b947b2bd6 100644 --- a/auth-server/Dockerfile +++ b/auth-server/Dockerfile @@ -1,6 +1,6 @@ -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" COPY script/start.sh /start.sh diff --git a/auth-server/build.gradle b/auth-server/build.gradle index 6b2068029..5cd649233 100644 --- a/auth-server/build.gradle +++ b/auth-server/build.gradle @@ -1,7 +1,7 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "application" - id "com.github.johnrengelman.shadow" version "4.0.1" + id "com.github.johnrengelman.shadow" version "5.0.0" id "idea" } @@ -10,10 +10,12 @@ dependencies { implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" implementation project(":firebase-extensions") - implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" - - implementation "javax.xml.bind:jaxb-api:$jaxbVersion" - implementation "javax.activation:activation:$javaxActivationVersion" + implementation ("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") { + exclude group: "org.jetbrains.kotlin", module:"kotlin-reflect" + } + + runtimeOnly "javax.xml.bind:jaxb-api:$jaxbVersion" + runtimeOnly "javax.activation:activation:$javaxActivationVersion" testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" @@ -45,7 +47,7 @@ task pack(type: Zip, dependsOn: 'shadowJar') { } sourceSets { - integrationTest { + integration { kotlin { compileClasspath += main.output + test.output runtimeClasspath += main.output + test.output @@ -56,19 +58,21 @@ sourceSets { } configurations { - integrationTestImplementation.extendsFrom testImplementation - integrationTestCompile.extendsFrom testCompile - integrationTestRuntime.extendsFrom testRuntime + integration + integrationImplementation.extendsFrom testImplementation + integrationCompile.extendsFrom testCompile + integrationRuntime.extendsFrom testRuntime } -task integrationTest(type: Test, description: 'Runs the integration tests.', group: 'Verification') { - testClassesDirs = sourceSets.integrationTest.output.classesDirs - classpath = sourceSets.integrationTest.runtimeClasspath +task integration(type: Test, description: 'Runs the integration tests.', group: 'Verification') { + testClassesDirs = sourceSets.integration.output.classesDirs + classpath = sourceSets.integration.runtimeClasspath } -integrationTest.mustRunAfter test +integration.mustRunAfter test +build.dependsOn integration -apply from: '../jacoco.gradle' +apply from: '../gradle/jacoco.gradle' idea { module { diff --git a/auth-server/config/.gitignore b/auth-server/config/.gitignore index bf045303f..3b858adea 100644 --- a/auth-server/config/.gitignore +++ b/auth-server/config/.gitignore @@ -1 +1 @@ -pantel-prod.json \ No newline at end of file +prime-service-account.json \ No newline at end of file diff --git a/auth-server/config/config.yaml b/auth-server/config/config.yaml index 428ced5ac..862cc47cb 100644 --- a/auth-server/config/config.yaml +++ b/auth-server/config/config.yaml @@ -1,4 +1,4 @@ -serviceAccountKey: /config/pantel-prod.json +serviceAccountKey: /config/prime-service-account.json logging: level: INFO diff --git a/auth-server/src/integration-test/kotlin/org/ostelco/auth/AuthServerTest.kt b/auth-server/src/integration-test/kotlin/org/ostelco/auth/AuthServerTest.kt index e12a47831..846fbba8d 100644 --- a/auth-server/src/integration-test/kotlin/org/ostelco/auth/AuthServerTest.kt +++ b/auth-server/src/integration-test/kotlin/org/ostelco/auth/AuthServerTest.kt @@ -26,7 +26,7 @@ class AuthServerTest { fun testAuthServer() { val response = JerseyClientBuilder().build() - ?.target("http://0.0.0.0:${RULE.getLocalPort()}/auth/token") + ?.target("http://0.0.0.0:${RULE.localPort}/auth/token") ?.request() ?.header("X-MSISDN", msisdn) ?.get() diff --git a/auth-server/src/integration-test/resources/config.yaml b/auth-server/src/integration-test/resources/config.yaml index 5bc9240bb..49f3007b8 100644 --- a/auth-server/src/integration-test/resources/config.yaml +++ b/auth-server/src/integration-test/resources/config.yaml @@ -1,4 +1,4 @@ -serviceAccountKey: config/pantel-prod.json +serviceAccountKey: config/prime-service-account.json logging: level: INFO diff --git a/auth-server/src/main/kotlin/org/ostelco/auth/AuthServerApplication.kt b/auth-server/src/main/kotlin/org/ostelco/auth/AuthServerApplication.kt index 5e689888a..937301b76 100644 --- a/auth-server/src/main/kotlin/org/ostelco/auth/AuthServerApplication.kt +++ b/auth-server/src/main/kotlin/org/ostelco/auth/AuthServerApplication.kt @@ -16,9 +16,7 @@ import org.slf4j.LoggerFactory /** * Entry point for running the authentiation server application */ -fun main(args: Array) { - AuthServerApplication().run(*args) -} +fun main(args: Array) = AuthServerApplication().run(*args) /** * A Dropwizard application for running an authentication service that diff --git a/auth-server/src/test/kotlin/org/ostelco/auth/AuthResourceTest.kt b/auth-server/src/test/kotlin/org/ostelco/auth/AuthResourceTest.kt index 3d4dd028c..5f1927e4a 100644 --- a/auth-server/src/test/kotlin/org/ostelco/auth/AuthResourceTest.kt +++ b/auth-server/src/test/kotlin/org/ostelco/auth/AuthResourceTest.kt @@ -13,7 +13,7 @@ class AuthResourceTest { @JvmField @ClassRule - val resources = ResourceTestRule.builder() + val resources: ResourceTestRule = ResourceTestRule.builder() .addResource(AuthResource()) .build() } diff --git a/bq-metrics-extractor/Dockerfile b/bq-metrics-extractor/Dockerfile index ed8144f51..0accc9583 100644 --- a/bq-metrics-extractor/Dockerfile +++ b/bq-metrics-extractor/Dockerfile @@ -1,6 +1,6 @@ -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" # # Copy the files we need @@ -25,4 +25,4 @@ RUN ["java", "-Dfile.encoding=UTF-8", "-Xshare:on", "-Xshare:dump", "-jar", "/bq # Finally the actual entry point # -ENTRYPOINT ["/start.sh"] +CMD ["/start.sh"] diff --git a/bq-metrics-extractor/Dockerfile.test b/bq-metrics-extractor/Dockerfile.test index fda30d0b5..3be7c8208 100644 --- a/bq-metrics-extractor/Dockerfile.test +++ b/bq-metrics-extractor/Dockerfile.test @@ -1,13 +1,13 @@ -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" # # Copy the files we need # COPY script/start.sh /start.sh -COPY config/pantel-prod.json /secret/pantel-prod.json +COPY config/prime-service-account.json /secret/prime-service-account.json COPY config/config.yaml /config/config.yaml COPY build/libs/bq-metrics-extractor-uber.jar /bq-metrics-extractor.jar diff --git a/bq-metrics-extractor/README.md b/bq-metrics-extractor/README.md index 66b863eb0..b63c4931c 100644 --- a/bq-metrics-extractor/README.md +++ b/bq-metrics-extractor/README.md @@ -37,7 +37,7 @@ The config.yaml file contains specifications of queries and how they map to metr help: Number of active users resultColumn: count sql: > - SELECT count(distinct user_pseudo_id) AS count FROM `pantel-2decb.analytics_160712959.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `$GCP_PROJECT_ID.analytics_160712959.events_*` WHERE event_name = "first_open" LIMIT 1000 @@ -49,6 +49,9 @@ If not running in a google kubernetes cluster (e.g. in docker compose, or from t it's necessary to set the environment variable GOOGLE_APPLICATION_CREDENTIALS to point to a credentials file that will provide access for the BigQuery library. +Set the table name for analytics data from firebase + + kubectl create secret generic analytics-secrets --from-literal=analyticsDatasetName='analytics_180820127' --namespace dev How to build and deploy the cronjob manually diff --git a/bq-metrics-extractor/build.gradle b/bq-metrics-extractor/build.gradle index 764e36fd7..32d2570b8 100644 --- a/bq-metrics-extractor/build.gradle +++ b/bq-metrics-extractor/build.gradle @@ -3,9 +3,9 @@ buildscript { } plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "application" - id "com.github.johnrengelman.shadow" version "4.0.1" + id "com.github.johnrengelman.shadow" version "5.0.0" id "idea" } @@ -16,11 +16,12 @@ dependencies { implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinXCoroutinesVersion" + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" implementation "com.google.cloud:google-cloud-bigquery:$googleCloudVersion" - implementation 'io.prometheus:simpleclient_pushgateway:0.5.0' + implementation 'io.prometheus:simpleclient_pushgateway:0.6.0' runtimeOnly "io.dropwizard:dropwizard-json-logging:$dropwizardVersion" @@ -38,4 +39,4 @@ shadowJar { version = null } -apply from: '../jacoco.gradle' \ No newline at end of file +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/bq-metrics-extractor/config/.gitignore b/bq-metrics-extractor/config/.gitignore index bf045303f..3b858adea 100644 --- a/bq-metrics-extractor/config/.gitignore +++ b/bq-metrics-extractor/config/.gitignore @@ -1 +1 @@ -pantel-prod.json \ No newline at end of file +prime-service-account.json \ No newline at end of file diff --git a/bq-metrics-extractor/config/config.yaml b/bq-metrics-extractor/config/config.yaml index b1f9ffeaa..bb1b79ca8 100644 --- a/bq-metrics-extractor/config/config.yaml +++ b/bq-metrics-extractor/config/config.yaml @@ -15,7 +15,7 @@ bqmetrics: help: Number of active application users last 24 hours resultColumn: count sql: > - SELECT count(distinct user_pseudo_id) AS count FROM `${DATASET_PROJECT}.analytics_160712959.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `${DATASET_PROJECT}.${ANALYTICS_DATASET}.events_*` WHERE (event_name = "session_start" OR event_name = "screen_view" OR event_name = "user_engagement") AND timestamp_micros(event_timestamp) >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY) - type: gauge @@ -23,7 +23,7 @@ bqmetrics: help: Number of active application users yesterday resultColumn: count sql: > - SELECT count(distinct user_pseudo_id) AS count FROM `${DATASET_PROJECT}.analytics_160712959.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `${DATASET_PROJECT}.${ANALYTICS_DATASET}.events_*` WHERE (event_name = "session_start" OR event_name = "screen_view" OR event_name = "user_engagement") AND timestamp_micros(event_timestamp) >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY) AND timestamp_micros(event_timestamp) < TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY) @@ -32,7 +32,7 @@ bqmetrics: help: Number of active application users today resultColumn: count sql: > - SELECT count(distinct user_pseudo_id) AS count FROM `${DATASET_PROJECT}.analytics_160712959.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `${DATASET_PROJECT}.${ANALYTICS_DATASET}.events_*` WHERE (event_name = "session_start" OR event_name = "screen_view" OR event_name = "user_engagement") AND timestamp_micros(event_timestamp) >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY) - type: gauge @@ -107,6 +107,27 @@ bqmetrics: SELECT COUNT (DISTINCT user.msisdn) AS count FROM `${DATASET_PROJECT}.ocs_gateway${DATASET_MODIFIER}.raw_activeusers`, UNNEST(users) as user WHERE timestamp >= TIMESTAMP_SUB(TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY), INTERVAL 1 DAY) AND timestamp < TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY) + + - type: gauge + name: sims_who_have_been_active_today_roaming + help: Number of SIMs that has had an active data session while roaming today + resultColumn: count + sql: > + SELECT COUNT (DISTINCT user.msisdn) AS count FROM `${DATASET_PROJECT}.ocs_gateway${DATASET_MODIFIER}.raw_activeusers`, UNNEST(users) as user + WHERE timestamp >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY) + AND apn = "loltel-test" + AND mccMnc != "24201" + - type: gauge + name: sims_who_was_active_yesterday_roaming + help: Number of SIMs that has had an active data session while roaming yesterday + resultColumn: count + sql: > + SELECT COUNT (DISTINCT user.msisdn) AS count FROM `${DATASET_PROJECT}.ocs_gateway${DATASET_MODIFIER}.raw_activeusers`, UNNEST(users) as user + WHERE timestamp >= TIMESTAMP_SUB(TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY), INTERVAL 1 DAY) + AND timestamp < TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY) + AND apn = "loltel-test" + AND mccMnc != "24201" + - type: gauge name: total_data_used_today help: Total data used today diff --git a/bq-metrics-extractor/cronjob/deploy-dev-direct.sh b/bq-metrics-extractor/cronjob/deploy-dev-direct.sh index 4c51588cd..8976d4640 100755 --- a/bq-metrics-extractor/cronjob/deploy-dev-direct.sh +++ b/bq-metrics-extractor/cronjob/deploy-dev-direct.sh @@ -9,21 +9,21 @@ fi kubectl config use-context $(kubectl config get-contexts --output name | grep dev-cluster) -PROJECT_ID="$(gcloud config get-value project -q)" +GCP_PROJECT_ID="$(gcloud config get-value project -q)" EXTRACTOR_VERSION="$(gradle bq-metrics-extractor:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" SHORT_SHA="$(git log -1 --pretty=format:%h)" TAG="${EXTRACTOR_VERSION}-${SHORT_SHA}-dev" -echo PROJECT_ID=${PROJECT_ID} +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} echo EXTRACTOR_VERSION=${EXTRACTOR_VERSION} echo SHORT_SHA=${SHORT_SHA} echo TAG=${TAG} gradle bq-metrics-extractor:clean bq-metrics-extractor:build -docker build -t eu.gcr.io/${PROJECT_ID}/bq-metrics-extractor:${TAG} bq-metrics-extractor -docker push eu.gcr.io/${PROJECT_ID}/bq-metrics-extractor:${TAG} +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/bq-metrics-extractor:${TAG} bq-metrics-extractor +docker push eu.gcr.io/${GCP_PROJECT_ID}/bq-metrics-extractor:${TAG} echo "Deploying bq-metrics-extractor to GKE" -sed -e s/EXTRACTOR_VERSION/${TAG}/g bq-metrics-extractor/cronjob/extractor-dev.yaml | kubectl apply -f - +sed -e 's/EXTRACTOR_VERSION/'"${TAG}"'/g; s/GCP_PROJECT_ID/'"${GCP_PROJECT_ID}"'/g' bq-metrics-extractor/cronjob/extractor-dev.yaml | kubectl apply -f - diff --git a/bq-metrics-extractor/cronjob/deploy-direct.sh b/bq-metrics-extractor/cronjob/deploy-direct.sh index 4576b6d3a..1f841b9bf 100755 --- a/bq-metrics-extractor/cronjob/deploy-direct.sh +++ b/bq-metrics-extractor/cronjob/deploy-direct.sh @@ -9,21 +9,21 @@ fi kubectl config use-context $(kubectl config get-contexts --output name | grep private-cluster) -PROJECT_ID="$(gcloud config get-value project -q)" +GCP_PROJECT_ID="$(gcloud config get-value project -q)" EXTRACTOR_VERSION="$(gradle bq-metrics-extractor:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" SHORT_SHA="$(git log -1 --pretty=format:%h)" TAG="${EXTRACTOR_VERSION}-${SHORT_SHA}" -echo PROJECT_ID=${PROJECT_ID} +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} echo EXTRACTOR_VERSION=${EXTRACTOR_VERSION} echo SHORT_SHA=${SHORT_SHA} echo TAG=${TAG} gradle bq-metrics-extractor:clean bq-metrics-extractor:build -docker build -t eu.gcr.io/${PROJECT_ID}/bq-metrics-extractor:${TAG} bq-metrics-extractor -docker push eu.gcr.io/${PROJECT_ID}/bq-metrics-extractor:${TAG} +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/bq-metrics-extractor:${TAG} bq-metrics-extractor +docker push eu.gcr.io/${GCP_PROJECT_ID}/bq-metrics-extractor:${TAG} echo "Deploying bq-metrics-extractor to GKE" -sed -e s/EXTRACTOR_VERSION/${TAG}/g bq-metrics-extractor/cronjob/extractor.yaml | kubectl apply -f - +sed -e 's/EXTRACTOR_VERSION/'"${TAG}"'/g; s/GCP_PROJECT_ID/'"${GCP_PROJECT_ID}"'/g' bq-metrics-extractor/cronjob/extractor.yaml | kubectl apply -f - diff --git a/bq-metrics-extractor/cronjob/extractor-dev.yaml b/bq-metrics-extractor/cronjob/extractor-dev.yaml index 70755976d..2e4b699b0 100644 --- a/bq-metrics-extractor/cronjob/extractor-dev.yaml +++ b/bq-metrics-extractor/cronjob/extractor-dev.yaml @@ -10,11 +10,11 @@ spec: spec: containers: - name: bq-metrics-extractor - image: eu.gcr.io/pantel-2decb/bq-metrics-extractor:EXTRACTOR_VERSION + image: eu.gcr.io/GCP_PROJECT_ID/bq-metrics-extractor:EXTRACTOR_VERSION imagePullPolicy: Always env: - name: DATASET_PROJECT - value: pantel-2decb + value: GCP_PROJECT_ID - name: DATASET_MODIFIER value: _dev restartPolicy: Never diff --git a/bq-metrics-extractor/cronjob/extractor.yaml b/bq-metrics-extractor/cronjob/extractor.yaml index 9262a0967..b5bcf0a4c 100644 --- a/bq-metrics-extractor/cronjob/extractor.yaml +++ b/bq-metrics-extractor/cronjob/extractor.yaml @@ -10,9 +10,9 @@ spec: spec: containers: - name: bq-metrics-extractor - image: eu.gcr.io/pantel-2decb/bq-metrics-extractor:EXTRACTOR_VERSION + image: eu.gcr.io/GCP_PROJECT_ID/bq-metrics-extractor:EXTRACTOR_VERSION imagePullPolicy: Always env: - name: DATASET_PROJECT - value: pantel-2decb + value: GCP_PROJECT_ID restartPolicy: Never diff --git a/bq-metrics-extractor/docker-compose.yml b/bq-metrics-extractor/docker-compose.yml index 33c79698a..044d85a57 100644 --- a/bq-metrics-extractor/docker-compose.yml +++ b/bq-metrics-extractor/docker-compose.yml @@ -18,7 +18,7 @@ services: depends_on: - pushgateway environment: - - GOOGLE_APPLICATION_CREDENTIALS=/secret/pantel-prod.json + - GOOGLE_APPLICATION_CREDENTIALS=/secret/prime-service-account.json emulator: container_name: emulator diff --git a/bq-metrics-extractor/script/start.sh b/bq-metrics-extractor/script/start.sh index 6081dbe61..ec77b6d4f 100755 --- a/bq-metrics-extractor/script/start.sh +++ b/bq-metrics-extractor/script/start.sh @@ -4,4 +4,4 @@ exec java \ -Dfile.encoding=UTF-8 \ -Xshare:on \ - -jar /bq-metrics-extractor.jar query --pushgateway pushgateway:8080 config/config.yaml + -jar /bq-metrics-extractor.jar query --pushgateway prometheus-pushgateway.kube-system.svc.cluster.local:9091 config/config.yaml diff --git a/bq-metrics-extractor/src/main/kotlin/org/ostelco/bqmetrics/BqMetricsExtractorApplication.kt b/bq-metrics-extractor/src/main/kotlin/org/ostelco/bqmetrics/BqMetricsExtractorApplication.kt index a0e6b0874..7e8b55fb3 100644 --- a/bq-metrics-extractor/src/main/kotlin/org/ostelco/bqmetrics/BqMetricsExtractorApplication.kt +++ b/bq-metrics-extractor/src/main/kotlin/org/ostelco/bqmetrics/BqMetricsExtractorApplication.kt @@ -16,7 +16,10 @@ import io.prometheus.client.CollectorRegistry import io.prometheus.client.Gauge import io.prometheus.client.Summary import io.prometheus.client.exporter.PushGateway -import kotlinx.coroutines.experimental.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import net.sourceforge.argparse4j.inf.Namespace import net.sourceforge.argparse4j.inf.Subparser import org.slf4j.Logger @@ -47,7 +50,7 @@ import com.google.cloud.bigquery.Job as BQJob * help: Number of active users * resultColumn: count * sql: > - * SELECT count(distinct user_pseudo_id) AS count FROM `pantel-2decb.analytics_160712959.events_*` + * SELECT count(distinct user_pseudo_id) AS count FROM `GCP_PROJECT_ID.analytics_160712959.events_*` * WHERE event_name = "first_open" * LIMIT 1000 * @@ -64,9 +67,7 @@ import com.google.cloud.bigquery.Job as BQJob /** * Main entry point, invoke dropwizard application. */ -fun main(args: Array) { - BqMetricsExtractorApplication().run(*args) -} +fun main(args: Array) = BqMetricsExtractorApplication().run(*args) /** * Config of a single metric that will be extracted using a BigQuery @@ -163,9 +164,9 @@ open class EnvironmentVars { abstract class MetricBuilder( val metricName: String, val help: String, - val sql: String, - val resultColumn: String, - val env: EnvironmentVars) { + private val sql: String, + private val resultColumn: String, + private val env: EnvironmentVars) { /** * Function which will add the current value of the metric to registry. @@ -176,7 +177,7 @@ abstract class MetricBuilder( * Function to expand the environment variables in the SQL. */ fun expandSql(): String { - val regex:Regex = "\\$\\{\\S*?\\}".toRegex(RegexOption.MULTILINE); + val regex:Regex = "\\$\\{\\S*?\\}".toRegex(RegexOption.MULTILINE) val expandedSql = regex.replace(sql) {it: MatchResult -> // The variable is of the format ${VAR} // extract variable name @@ -202,13 +203,13 @@ abstract class MetricBuilder( QueryJobConfiguration.newBuilder( expandSql()) .setUseLegacySql(false) - .build(); + .build() // Create a job ID so that we can safely retry. - val jobId: JobId = JobId.of(UUID.randomUUID().toString()); - var queryJob: BQJob = bigquery.create(JobInfo.newBuilder(queryConfig).setJobId(jobId).build()); + val jobId: JobId = JobId.of(UUID.randomUUID().toString()) + var queryJob: BQJob = bigquery.create(JobInfo.newBuilder(queryConfig).setJobId(jobId).build()) - // Wait for the query to complete. + // Wait for the query to complete. // Retry maximum 4 times for up to 2 minutes. queryJob = async { queryJob.waitFor( @@ -216,16 +217,16 @@ abstract class MetricBuilder( RetryOption.retryDelayMultiplier(2.0), RetryOption.maxRetryDelay(Duration.ofSeconds(20)), RetryOption.maxAttempts(5), - RetryOption.totalTimeout(Duration.ofMinutes(2))); + RetryOption.totalTimeout(Duration.ofMinutes(2))) }.await() // Check for errors if (queryJob == null) { - throw BqMetricsExtractionException("Job no longer exists"); - } else if (queryJob.getStatus().getError() != null) { + throw BqMetricsExtractionException("Job no longer exists") + } else if (queryJob.status.error != null) { // You can also look at queryJob.getStatus().getExecutionErrors() for all // errors, not just the latest one. - throw BqMetricsExtractionException(queryJob.getStatus().getError().toString()); + throw BqMetricsExtractionException(queryJob.status.error.toString()) } val result = queryJob.getQueryResults() if (result.totalRows != 1L) { diff --git a/bq-metrics-extractor/src/test/kotlin/org/ostelco/bqmetrics/MetricBuildersTest.kt b/bq-metrics-extractor/src/test/kotlin/org/ostelco/bqmetrics/MetricBuildersTest.kt index 21cc68cc1..147c15786 100644 --- a/bq-metrics-extractor/src/test/kotlin/org/ostelco/bqmetrics/MetricBuildersTest.kt +++ b/bq-metrics-extractor/src/test/kotlin/org/ostelco/bqmetrics/MetricBuildersTest.kt @@ -1,9 +1,9 @@ package org.ostelco.bqmetrics +import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import kotlin.test.Test import kotlin.test.assertEquals -import org.mockito.Mockito.`when` import kotlin.test.assertNotEquals /** @@ -14,13 +14,13 @@ class MetricBuildersTest { @Test fun testSQLNoVars() { val testEnvVars = mock(EnvironmentVars::class.java) - `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn("pantel-2decb") + `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn(GCP_PROJECT_ID) `when`(testEnvVars.getVar("DATASET_MODIFIER")).thenReturn("_dev") val sql = """ - SELECT count(distinct user_pseudo_id) AS count FROM `pantel-2decb.analytics_160712959.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `$GCP_PROJECT_ID.analytics_160712959.events_*` WHERE event_name = "first_open" """ - val metric: SummaryMetricBuilder = SummaryMetricBuilder( + val metric = SummaryMetricBuilder( metricName = "metric1", help = "none", sql = sql, @@ -32,17 +32,17 @@ class MetricBuildersTest { @Test fun testSQL2Vars() { val testEnvVars = mock(EnvironmentVars::class.java) - `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn("pantel-2decb") + `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn(GCP_PROJECT_ID) `when`(testEnvVars.getVar("DATASET_MODIFIER")).thenReturn("_dev") val sql = """ SELECT count(distinct user_pseudo_id) AS count FROM `${'$'}{DATASET_PROJECT}.analytics_160712959${'$'}{DATASET_MODIFIER}.events_*` WHERE event_name = "first_open" """ val sqlResult = """ - SELECT count(distinct user_pseudo_id) AS count FROM `pantel-2decb.analytics_160712959_dev.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `$GCP_PROJECT_ID.analytics_160712959_dev.events_*` WHERE event_name = "first_open" """ - val metric: SummaryMetricBuilder = SummaryMetricBuilder( + val metric = SummaryMetricBuilder( metricName = "metric1", help = "none", sql = sql, @@ -55,17 +55,17 @@ class MetricBuildersTest { @Test fun testSQLUnknownVar() { val testEnvVars = mock(EnvironmentVars::class.java) - `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn("pantel-2decb") + `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn(GCP_PROJECT_ID) `when`(testEnvVars.getVar("DATASET_MODIFIER")).thenReturn(null) val sql = """ SELECT count(distinct user_pseudo_id) AS count FROM `${'$'}{DATASET_PROJECT}.analytics_160712959${'$'}{DATASET_MODIFIER}.events_*` WHERE event_name = "first_open" """ val sqlResult = """ - SELECT count(distinct user_pseudo_id) AS count FROM `pantel-2decb.analytics_160712959.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `$GCP_PROJECT_ID.analytics_160712959.events_*` WHERE event_name = "first_open" """ - val metric: SummaryMetricBuilder = SummaryMetricBuilder( + val metric = SummaryMetricBuilder( metricName = "metric1", help = "none", sql = sql, @@ -78,17 +78,17 @@ class MetricBuildersTest { @Test fun testMangleBadSQL() { val testEnvVars = mock(EnvironmentVars::class.java) - `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn("pantel-2decb") + `when`(testEnvVars.getVar("DATASET_PROJECT")).thenReturn(GCP_PROJECT_ID) `when`(testEnvVars.getVar("DATASET_MODIFIER")).thenReturn("; DELETE * from abc;") val sql = """ SELECT count(distinct user_pseudo_id) AS count FROM `${'$'}{DATASET_PROJECT}.analytics_160712959${'$'}{DATASET_MODIFIER}.events_*` WHERE event_name = "first_open" """ val sqlResult = """ - SELECT count(distinct user_pseudo_id) AS count FROM `pantel-2decb.analytics_160712959; DELETE * from abc;.events_*` + SELECT count(distinct user_pseudo_id) AS count FROM `$GCP_PROJECT_ID.analytics_160712959; DELETE * from abc;.events_*` WHERE event_name = "first_open" """ - val metric: SummaryMetricBuilder = SummaryMetricBuilder( + val metric = SummaryMetricBuilder( metricName = "metric1", help = "none", sql = sql, @@ -98,4 +98,8 @@ class MetricBuildersTest { println(metric.expandSql()) assertNotEquals(metric.expandSql(), sqlResult.trimIndent()) } + + companion object { + private const val GCP_PROJECT_ID = "GCP_PROJECT_ID" + } } diff --git a/build-all.sh b/build-all.sh new file mode 100755 index 000000000..233811590 --- /dev/null +++ b/build-all.sh @@ -0,0 +1,177 @@ +#!/bin/bash + +## +## Build script that will build everything and run acceptance tests. +## Prior to running this script it is necesary to set th4 +## STRIPE_API_KEY environment variable to a key that is valid for a +## Stripe test account. See instructions in docs/TEST.md for how to +## get one. +## + + + +# +# Cd to script directory +# + +cd $(dirname $0) + + +DEPENDENCIES="docker-compose ./gradlew docker cmp" + +# +# Do we have the dependencies (in this case only gradle, but copy/paste +# made the test more generic .-) +# +for dep in $DEPENDENCIES ; do + if [[ -z "$(which $dep)" ]] ; then + echo "Couldn't find dependency $dep" + exit 1 + fi +done + + +# +# Generate certificates for ESP endpoints, if needed +# (the script will check if they are needed) +# + +if [[ -f "certs/ocs.dev.ostelco.org/nginx.crt" ]] ; then + if [[ -f "ocsgw/cert/metrics.crt" ]] ; then + if [[ -n "$(cmp certs/ocs.dev.ostelco.org/nginx.crt ocsgw/cert/metrics.crt)" ]] ; then + rm certs/ocs.dev.ostelco.org/nginx.crt ocsgw/cert/metrics.crt + fi + fi +fi + + +if [[ ! -f "certs/ocs.dev.ostelco.org/nginx.crt" ]] ; then + scripts/generate-selfsigned-ssl-certs.sh ocs.dev.ostelco.org +fi + +if [[ ! -f "ocsgw/cert/metrics.crt" ]] ; then + cp certs/ocs.dev.ostelco.org/nginx.crt ocsgw/cert/ocs.crt +fi + + + +if [[ -f "certs/metrics.dev.ostelco.org/nginx.crt" ]] ; then + if [[ "ocsgw/cert/metrics.crt" ]] ; then + if [[ -n "$(cmp certs/metrics.dev.ostelco.org/nginx.crt ocsgw/cert/metrics.crt)" ]] ; then + rm "certs/metrics.dev.ostelco.org/nginx.crt" "ocsgw/cert/metrics.crt" + fi + fi +fi + +if [[ ! -f "certs/metrics.dev.ostelco.org/nginx.crt" ]] ; then + scripts/generate-selfsigned-ssl-certs.sh metrics.dev.ostelco.org +fi + +if [[ ! -f "ocsgw/cert/metrics.crt" ]] ; then + cp certs/metrics.dev.ostelco.org/nginx.crt ocsgw/cert/metrics.crt +fi + + +# +# Ensure that the GCP project is known to building process +# + +if [[ -z "$GCP_PROJECT_ID" ]] ; then + echo "You need to set the GCP_PROJECT_ID otherwise we'll not be able to run acceptance tests" + exit 1 +fi + +# +# Setting up service account files where they are needed. +# + + +DIRS_THAT_NEEDS_SERVICE_ACCOUNT_CONFIGS=" \ + acceptance-tests/config \ + dataflow-pipelines/config \ + ocsgw/config \ + bq-metrics-extractor/config \ + auth-server/config prime/config" + +SERVICE_ACCOUNT_MD5="b8d22f87b55431ebfa4b68c16d7dc037" + +if [[ ! -f "prime-service-account.json" ]] ; then + echo "$0 ERROR: Could not find master service-account file prime-service-account.json, aborting." + exit 1 +fi + +if [[ $(md5 -q "prime-service-account.json") != $SERVICE_ACCOUNT_MD5 ]] ; then + echo "$0 ERROR: MD5 checksum of service account file prime-service-account.json does not match expectation, aborting." + exit 1 +fi + +for DIR in $DIRS_THAT_NEEDS_SERVICE_ACCOUNT_CONFIGS ; do + echo "Checking $DIR" + FILE="$DIR/prime-service-account.json" + if [[ ! -f $FILE ]] ; then + if [[ $(md5 -q "prime-service-account.json") = $SERVICE_ACCOUNT_MD5 ]] ; then + echo "$0 INFO: Couldn't find service account file '$FILE' so copying one from root directory" + cp "prime-service-account.json" "$FILE" + else + echo "$0 ERROR: Could not find service account file $FILE, aborting." + exit 1 + fi + fi + if [[ $(md5 -q $FILE) != $SERVICE_ACCOUNT_MD5 ]] ; then + if [[ $(md5 -q "prime-service-account.json") = $SERVICE_ACCOUNT_MD5 ]] ; then + echo "$0 INFO: Service account '$FILE' has wrong MD5 checksum, but the one in the root directory has the right one, so we're copying that." + cp "prime-service-account.json" "$FILE" + else + echo "$0 ERROR: MD5 checksum of service account file '$FILE' is not $SERVICE_ACCOUNT_MD5, aborting." + exit 1 + fi + fi +done + + + +# +# Do we have the necessary environment variables set +# to run payment tests? +# + +if [[ -z "$STRIPE_API_KEY" ]] ; then + echo "$0 ERROR: STRIPE_API_KEY is not set. Se instructions in docs/TEST.md for how to get one." + exit 1 +fi + +if [[ -z "$STRIPE_ENDPOINT_SECRET" ]] ; then + export STRIPE_ENDPOINT_SECRET=thisIsARandomString + echo "$0 INFO: Couldn't find variable STRIPE_ENDPOINT_SECRET, setting it to dummy value '$STRIPE_ENDPOINT_SECRET'" +fi + + +if [[ -z "$( docker version | grep Version:)" ]] ; then + echo "$0 INFO: Docker not running, please start it before trying again'" + exit 1 +fi + + +# +# Then start running the build +# + +./gradlew build + +# +# If that didn't go too well, then bail out. +# + +if [[ $? -ne 0 ]] ; then echo + echo "Compilation failed, aborting. Not running acceptance tests." + exit 1 +fi + +# +# .... but it did go well, so we'll proceed to acceptance test +# + +echo "$0 INFO: Building/unit tests went well, Proceeding to acceptance tests." + +docker-compose down +docker-compose up --build --abort-on-container-exit diff --git a/build.gradle b/build.gradle index b4763aff3..b86bcccc9 100644 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,9 @@ plugins { id "java" id "project-report" - id "com.github.ben-manes.versions" version "0.20.0" + id "com.github.ben-manes.versions" version "0.21.0" id "jacoco" + id "org.jetbrains.kotlin.jvm" version "1.3.30" apply false } allprojects { @@ -15,9 +16,10 @@ allprojects { repositories { mavenCentral() jcenter() - maven { url = "https://repository.jboss.org/nexus/content/repositories/releases/" } + maven { url = "http://repository.jboss.org/nexus/content/repositories/releases/" } maven { url = "https://maven.repository.redhat.com/ga/" } maven { url = "http://clojars.org/repo/" } + maven { url "https://jitpack.io" } } jacoco { @@ -32,58 +34,48 @@ subprojects { options.encoding = 'UTF-8' } ext { - kotlinVersion = "1.2.71" - dropwizardVersion = "1.3.7" - kotlinXCoroutinesVersion = "0.30.2" - googleCloudVersion = "1.49.0" - jacksonVersion = "2.9.7" - stripeVersion = "7.1.0" - guavaVersion = "26.0-jre" - junit5Version = "5.3.1" - assertJVersion = "3.11.1" - mockitoVersion = "2.23.0" - firebaseVersion = "6.5.0" - beamVersion = "2.7.0" - // Keeping it version 1.15.1 to be consistent with grpc via PubSub client lib - // Keeping it version 1.15.1 to be consistent with netty via Firebase lib - grpcVersion = "1.15.1" - jaxbVersion = "2.3.0" + assertJVersion = "3.12.2" + arrowVersion = "0.8.2" + beamVersion = "2.11.0" + cxfVersion = "3.3.1" + dockerComposeJunitRuleVersion = "0.35.0" + dropwizardVersion = "1.3.9" + metricsVersion = "4.0.5" + firebaseVersion = "6.8.0" + googleCloudVersion = "1.69.0" + grpcVersion = "1.20.0" + guavaVersion = "27.1-jre" + jacksonVersion = "2.9.8" javaxActivationVersion = "1.1.1" + // Keeping it version 1.16.1 to be consistent with grpc via PubSub client lib + // Keeping it version 1.16.1 to be consistent with netty via Firebase lib + jaxbVersion = "2.3.1" + jjwtVersion = "0.9.1" + junit5Version = "5.4.2" + kotlinVersion = "1.3.30" + kotlinXCoroutinesVersion = "1.2.0" + mockitoVersion = "2.27.0" + neo4jDriverVersion="1.7.3" + neo4jVersion="3.5.4" + protocVersion="3.7.1" + slf4jVersion="1.7.26" + stripeVersion = "9.8.0" + swaggerVersion = "2.0.7" + swaggerCodegenVersion = "2.4.4" + tinkVersion = "1.2.2" + zxingVersion = "3.3.3" } } -task pack(dependsOn: ['packDev', 'packProd']) - -task packDev(type: Zip, dependsOn: [':ocsgw:packDev', ':auth-server:pack']) { - from zipTree('ocsgw/build/deploy/dev/ocsgw.zip') - from zipTree('auth-server/build/deploy/auth-server.zip') - from 'docker-compose.yaml' - from 'docker-compose.dev.yaml' - rename 'docker-compose.dev.yaml','docker-compose.override.yaml' - archiveName = 'ostelco-core-dev.zip' - destinationDir = file('build/deploy/') -} - -task packProd(type: Zip, dependsOn: [':ocsgw:packProd', ':auth-server:pack']) { - from zipTree('ocsgw/build/deploy/prod/ocsgw.zip') - from zipTree('auth-server/build/deploy/auth-server.zip') - from 'docker-compose.yaml' - from 'docker-compose.prod.yaml' - rename 'docker-compose.prod.yaml','docker-compose.override.yaml' - archiveName = 'ostelco-core-prod.zip' - destinationDir = file('build/deploy/') +dependencyUpdates.resolutionStrategy { + componentSelection { rules -> + rules.all { ComponentSelection selection -> + boolean rejected = ['alpha', 'beta', 'rc', 'cr', 'm', 'redhat'].any { qualifier -> + selection.candidate.version ==~ /(?i).*[.-]${qualifier}[.\d-]*/ + } + if (rejected) { + selection.reject('Release candidate') + } + } + } } - - -//dependencyUpdates.resolutionStrategy { -// componentSelection { rules -> -// rules.all { ComponentSelection selection -> -// boolean rejected = ['alpha', 'beta', 'rc', 'cr', 'm', 'redhat'].any { qualifier -> -// selection.candidate.version ==~ /(?i).*[.-]${qualifier}[.\d-]*/ -// } -// if (rejected) { -// selection.reject('Release candidate') -// } -// } -// } -//} \ No newline at end of file diff --git a/client-api/build.gradle b/client-api/build.gradle deleted file mode 100644 index 08217a648..000000000 --- a/client-api/build.gradle +++ /dev/null @@ -1,40 +0,0 @@ -plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" - id "java-library" -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - - implementation project(":prime-modules") - - implementation "io.dropwizard:dropwizard-auth:$dropwizardVersion" - implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" - - implementation "com.google.guava:guava:$guavaVersion" - implementation 'io.jsonwebtoken:jjwt:0.9.1' - - implementation "javax.xml.bind:jaxb-api:$jaxbVersion" - implementation "javax.activation:activation:$javaxActivationVersion" - - testImplementation "io.dropwizard:dropwizard-client:$dropwizardVersion" - testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" - testImplementation "org.mockito:mockito-core:$mockitoVersion" - testImplementation "org.assertj:assertj-core:$assertJVersion" - - testImplementation (group: 'org.glassfish.jersey.test-framework.providers', name: 'jersey-test-framework-provider-grizzly2', version: '2.25.1') { - because "Updating from 2.25.1 to 2.27.1 causes error. Keep the version matched with 'jersey-server' version from dropwizard." - exclude group: 'javax.servlet', module: 'javax.servlet-api' - exclude group: 'junit', module: 'junit' - } - - testImplementation "com.nhaarman:mockito-kotlin:1.6.0" -} - -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { - kotlinOptions { - jvmTarget = "1.8" - } -} - -apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/ClientApiConfiguration.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/ClientApiConfiguration.kt deleted file mode 100644 index bcfe43ef7..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/ClientApiConfiguration.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.ostelco.prime.client.api - -import com.fasterxml.jackson.annotation.JsonProperty -import com.google.common.cache.CacheBuilderSpec -import io.dropwizard.client.JerseyClientConfiguration -import javax.validation.Valid -import javax.validation.constraints.NotNull - -class ClientApiConfiguration { - - @Valid - @NotNull - @get:JsonProperty("authenticationCachePolicy") - var authenticationCachePolicy: CacheBuilderSpec? = null - private set - - @Valid - @NotNull - @get:JsonProperty("jerseyClient") - val jerseyClientConfiguration = JerseyClientConfiguration() - - @JsonProperty("authenticationCachePolicy") - fun setAuthenticationCachePolicy(spec: String) { - this.authenticationCachePolicy = CacheBuilderSpec.parse(spec) - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/ClientApiModule.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/ClientApiModule.kt deleted file mode 100644 index 52fdad641..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/ClientApiModule.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.ostelco.prime.client.api - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.annotation.JsonTypeName -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.dropwizard.auth.AuthDynamicFeature -import io.dropwizard.auth.AuthValueFactoryProvider -import io.dropwizard.auth.CachingAuthenticator -import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter.Builder -import io.dropwizard.client.JerseyClientBuilder -import io.dropwizard.setup.Environment -import org.eclipse.jetty.servlets.CrossOriginFilter -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.metrics.reportMetricsAtStartUp -import org.ostelco.prime.client.api.resources.AnalyticsResource -import org.ostelco.prime.client.api.resources.ApplicationTokenResource -import org.ostelco.prime.client.api.resources.BundlesResource -import org.ostelco.prime.client.api.resources.ConsentsResource -import org.ostelco.prime.client.api.resources.PaymentResource -import org.ostelco.prime.client.api.resources.ProductsResource -import org.ostelco.prime.client.api.resources.ProfileResource -import org.ostelco.prime.client.api.resources.PurchaseResource -import org.ostelco.prime.client.api.resources.ReferralResource -import org.ostelco.prime.client.api.resources.SubscriptionResource -import org.ostelco.prime.client.api.resources.SubscriptionsResource -import org.ostelco.prime.client.api.store.SubscriberDAOImpl -import org.ostelco.prime.module.PrimeModule -import org.ostelco.prime.module.getResource -import org.ostelco.prime.ocs.OcsSubscriberService -import org.ostelco.prime.storage.ClientDataSource -import java.util.* -import javax.servlet.DispatcherType -import javax.ws.rs.client.Client - - -/** - * Provides API for client. - * - */ -@JsonTypeName("api") -class ClientApiModule : PrimeModule { - - @JsonProperty("config") - private var config: ClientApiConfiguration = ClientApiConfiguration() - - private val storage by lazy { getResource() } - private val ocsSubscriberService by lazy { getResource() } - - override fun init(env: Environment) { - - // Allow CORS - val corsFilterRegistration = env.servlets().addFilter("CORS", CrossOriginFilter::class.java) - // Configure CORS parameters - corsFilterRegistration.setInitParameter("allowedOrigins", "*") - corsFilterRegistration.setInitParameter("allowedHeaders", - "Cache-Control,If-Modified-Since,Pragma,Content-Type,Authorization,X-Requested-With,Content-Length,Accept,Origin") - corsFilterRegistration.setInitParameter("allowedMethods", "OPTIONS,GET,PUT,POST,DELETE,HEAD") - corsFilterRegistration.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType::class.java), true, "/*") - - - val dao = SubscriberDAOImpl(storage, ocsSubscriberService) - val jerseyEnv = env.jersey() - - val client: Client = JerseyClientBuilder(env) - .using(config.jerseyClientConfiguration) - .using(jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)) - .build(env.name) - - /* APIs. */ - jerseyEnv.register(AnalyticsResource(dao)) - jerseyEnv.register(ConsentsResource(dao)) - jerseyEnv.register(ProductsResource(dao)) - jerseyEnv.register(PurchaseResource(dao)) - jerseyEnv.register(ProfileResource(dao)) - jerseyEnv.register(ReferralResource(dao)) - jerseyEnv.register(PaymentResource(dao)) - jerseyEnv.register(SubscriptionResource(dao)) - jerseyEnv.register(BundlesResource(dao)) - jerseyEnv.register(SubscriptionsResource(dao)) - jerseyEnv.register(ApplicationTokenResource(dao)) - - /* OAuth2 with cache. */ - val authenticator = CachingAuthenticator(env.metrics(), - OAuthAuthenticator(client), - config.authenticationCachePolicy) - - jerseyEnv.register(AuthDynamicFeature( - Builder() - .setAuthenticator(authenticator) - .setPrefix("Bearer") - .buildAuthFilter())) - jerseyEnv.register(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) - - reportMetricsAtStartUp() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/auth/AccessTokenPrincipal.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/auth/AccessTokenPrincipal.kt deleted file mode 100644 index 647fdc8c0..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/auth/AccessTokenPrincipal.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.ostelco.prime.client.api.auth - -import java.security.Principal - -/** - * Holds the 'user-id' obtained by verifying and decoding an OAuth2 - * 'access-token'. - */ -class AccessTokenPrincipal(private val subject: String) : Principal { - - override fun getName(): String { - return subject - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/core/EndpointUserInfo.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/core/EndpointUserInfo.kt deleted file mode 100644 index ecd6035a5..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/core/EndpointUserInfo.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.ostelco.prime.client.api.core - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.ostelco.prime.getLogger -import java.util.* - - -/** - * If running behind a Google Cloud Endpoint service the Endpoint service will add some - * information about the user in a HTTP header named: - * - * X-Endpoint-API-UserInfo - * - * This class can be used to 'capture' this information if present. - * - * To capture the information, add the following to the resource class: - * - * Valid @HeaderParam("X-Endpoint-API-UserInfo") EndpointUserInfo userInfo - * - * Ref.: https://cloud.google.com/endpoints/docs/openapi/authenticating-users - * Section: Receiving auth results in your API - */ -class EndpointUserInfo(enc: String) { - - private val logger by getLogger() - - private val mapper = jacksonObjectMapper() - - private val obj: JsonNode = mapper.readTree(decode(enc)) - private fun decode(enc: String): String = String(Base64.getDecoder().decode(enc)) - - val issuer: String? - get() = get("issuer") - - val id: String? - get() = get("id") - - val email: String? - get() = get("email") - - private operator fun get(key: String): String? = obj.get(key)?.textValue() - - override fun toString(): String = obj.toString() -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/health/HealthListener.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/health/HealthListener.kt deleted file mode 100644 index f1190fd3f..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/health/HealthListener.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.ostelco.prime.client.api.health - -/** - * Interface for 'health' monitoring. - */ -@FunctionalInterface -interface HealthListener { - - /** - * Returns 'health state' of connection. - * @return true if healthy - */ - val isHealthy: Boolean -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/model/Model.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/model/Model.kt deleted file mode 100644 index e11638041..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/model/Model.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.ostelco.prime.client.api.model - -import org.ostelco.prime.model.PurchaseRecord - -data class Consent( - var consentId: String? = null, - var description: String? = null, - var accepted: Boolean = false) - -data class ConsentList(val consents: List) - -//data class Grant( -// val grantType: String, -// val code: String, -// val refreshToken: String) - -data class SubscriptionStatus( - var remaining: Long = 0, - var purchaseRecords: List = emptyList()) - -data class Person(var name:String? = null) \ No newline at end of file diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/AnalyticsResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/AnalyticsResource.kt deleted file mode 100644 index 55581c397..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/AnalyticsResource.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson -import javax.validation.constraints.NotNull -import javax.ws.rs.Consumes -import javax.ws.rs.POST -import javax.ws.rs.Path -import javax.ws.rs.core.Response - -/** - * Analytics API. - * - */ -@Path("/analytics") -class AnalyticsResource(private val dao: SubscriberDAO) { - - @POST - @Consumes("application/json") - fun report(@Auth token: AccessTokenPrincipal?, - @NotNull event: String): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.reportAnalytics(token.name, event).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.CREATED) } - ).build() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ApplicationTokenResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ApplicationTokenResource.kt deleted file mode 100644 index 26a3bbb35..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ApplicationTokenResource.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson -import org.ostelco.prime.model.ApplicationToken -import javax.validation.constraints.NotNull -import javax.ws.rs.Consumes -import javax.ws.rs.POST -import javax.ws.rs.Path -import javax.ws.rs.Produces -import javax.ws.rs.core.Response - -/** - * ApplicationToken API. - * - */ -@Path("/applicationtoken") -class ApplicationTokenResource(private val dao: SubscriberDAO) { - - @POST - @Produces("application/json") - @Consumes("application/json") - fun storeApplicationToken(@Auth authToken: AccessTokenPrincipal?, - @NotNull applicationToken: ApplicationToken): Response { - if (authToken == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getMsisdn(authToken.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { msisdn -> - dao.storeApplicationToken(msisdn, applicationToken).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.CREATED).entity(asJson(it)) }) - }) - .build() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/BundlesResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/BundlesResource.kt deleted file mode 100644 index e204a1b4d..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/BundlesResource.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson -import javax.ws.rs.GET -import javax.ws.rs.Path -import javax.ws.rs.Produces -import javax.ws.rs.core.Response - -@Path("/bundles") -class BundlesResource(private val dao: SubscriberDAO) { - - @GET - @Produces("application/json") - fun getBundles(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getBundles(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } -} \ No newline at end of file diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ConsentsResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ConsentsResource.kt deleted file mode 100644 index ee38b2f72..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ConsentsResource.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson - -import javax.validation.constraints.NotNull -import javax.ws.rs.DefaultValue -import javax.ws.rs.GET -import javax.ws.rs.PUT -import javax.ws.rs.Path -import javax.ws.rs.PathParam -import javax.ws.rs.Produces -import javax.ws.rs.QueryParam -import javax.ws.rs.core.Response - -/** - * Consents API. - */ -@Path("/consents") -class ConsentsResource(private val dao: SubscriberDAO) { - - @GET - @Produces("application/json") - fun getConsents(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getConsents(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } - - @PUT - @Path("{consent-id}") - @Produces("application/json") - fun updateConsent(@Auth token: AccessTokenPrincipal?, - @NotNull - @PathParam("consent-id") - consentId: String, - @DefaultValue("true") @QueryParam("accepted") accepted: Boolean): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - val result = if (accepted) { - dao.acceptConsent(token.name, consentId) - } else { - dao.rejectConsent(token.name, consentId) - } - - return result.fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ProfileResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ProfileResource.kt deleted file mode 100644 index d06479f3d..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ProfileResource.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson -import org.ostelco.prime.model.Subscriber -import javax.validation.constraints.NotNull -import javax.ws.rs.Consumes -import javax.ws.rs.GET -import javax.ws.rs.POST -import javax.ws.rs.PUT -import javax.ws.rs.Path -import javax.ws.rs.Produces -import javax.ws.rs.QueryParam -import javax.ws.rs.core.Response - -/** - * Profile API. - * - */ -@Path("/profile") -class ProfileResource(private val dao: SubscriberDAO) { - - @GET - @Produces("application/json") - fun getProfile(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getProfile(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } - - @POST - @Produces("application/json") - @Consumes("application/json") - fun createProfile(@Auth token: AccessTokenPrincipal?, - @NotNull profile: Subscriber, - @QueryParam("referred_by") referredBy: String?): Response { - - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.createProfile(token.name, profile, referredBy).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.CREATED).entity(asJson(it)) }) - .build() - } - - @PUT - @Produces("application/json") - @Consumes("application/json") - fun updateProfile(@Auth token: AccessTokenPrincipal?, - @NotNull profile: Subscriber): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.updateProfile(token.name, profile).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/PurchaseResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/PurchaseResource.kt deleted file mode 100644 index e222fa2b9..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/PurchaseResource.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson -import javax.ws.rs.GET -import javax.ws.rs.Path -import javax.ws.rs.Produces -import javax.ws.rs.core.Response - -/** - * Purchase API. - * - */ -@Path("/purchases") -class PurchaseResource(private val dao: SubscriberDAO) { - - @GET - @Produces("application/json") - fun getPurchases(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getPurchaseHistory(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ReferralResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ReferralResource.kt deleted file mode 100644 index ebf210e48..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ReferralResource.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson -import javax.ws.rs.GET -import javax.ws.rs.Path -import javax.ws.rs.Produces -import javax.ws.rs.core.Response - -@Path("referred") -class ReferralResource(private val dao: SubscriberDAO) { - - @GET - @Produces("application/json") - fun getReferrals(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED).build() - } - - return dao.getReferrals(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(it) }) - .build() - } - - @GET - @Path("/by") - @Produces("application/json") - fun getReferredBy(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED).build() - } - - return dao.getReferredBy(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(it) }) - .build() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/SubscriptionResource.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/SubscriptionResource.kt deleted file mode 100644 index 28fc032b0..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/SubscriptionResource.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.jsonmapper.asJson -import javax.ws.rs.GET -import javax.ws.rs.Path -import javax.ws.rs.Produces -import javax.ws.rs.core.Response - -/** - * Subscriptions API. - * - */ - -@Path("/subscription") -@Deprecated("use SubscriptionsResource and/or BundlesResource", ReplaceWith("SubscriptionsResource", "org.ostelco.prime.client.api.resources.SubscriptionsResource")) -class SubscriptionResource(private val dao: SubscriberDAO) { - - @GET - @Path("status") - @Produces("application/json") - fun getSubscriptionStatus(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getSubscriptionStatus(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } - - @GET - @Path("activePseudonyms") - @Produces("application/json") - fun getActivePseudonyms(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getActivePseudonymOfMsisdnForSubscriber(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { pseudonym -> Response.status(Response.Status.OK).entity(pseudonym) }) - .build() - } -} - -@Path("/subscriptions") -class SubscriptionsResource(private val dao: SubscriberDAO) { - - @GET - @Produces("application/json") - fun getSubscription(@Auth token: AccessTokenPrincipal?): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.getSubscriptions(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/store/SubscriberDAO.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/store/SubscriberDAO.kt deleted file mode 100644 index 39f377634..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/store/SubscriberDAO.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.ostelco.prime.client.api.store - -import arrow.core.Either -import org.ostelco.prime.client.api.model.Consent -import org.ostelco.prime.client.api.model.Person -import org.ostelco.prime.client.api.model.SubscriptionStatus -import org.ostelco.prime.apierror.ApiError -import org.ostelco.prime.model.ActivePseudonyms -import org.ostelco.prime.model.ApplicationToken -import org.ostelco.prime.model.Bundle -import org.ostelco.prime.model.Product -import org.ostelco.prime.model.PurchaseRecord -import org.ostelco.prime.model.Subscriber -import org.ostelco.prime.model.Subscription -import org.ostelco.prime.paymentprocessor.core.ProductInfo -import org.ostelco.prime.paymentprocessor.core.SourceInfo -import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo - - -/** - * - */ -interface SubscriberDAO { - - fun getProfile(subscriberId: String): Either - - fun createProfile(subscriberId: String, profile: Subscriber, referredBy: String?): Either - - fun updateProfile(subscriberId: String, profile: Subscriber): Either - - @Deprecated("use getSubscriptions", ReplaceWith("getSubscriptions", "org.ostelco.prime.client.api.model.Subscription")) - fun getSubscriptionStatus(subscriberId: String): Either - - fun getSubscriptions(subscriberId: String): Either> - - fun getBundles(subscriberId: String): Either> - - fun getPurchaseHistory(subscriberId: String): Either> - - fun getProduct(subscriptionId: String, sku: String): Either - - fun getMsisdn(subscriberId: String): Either - - fun getProducts(subscriberId: String): Either> - - fun purchaseProduct(subscriberId: String, sku: String, sourceId: String?, saveCard: Boolean): Either - - fun getConsents(subscriberId: String): Either> - - fun acceptConsent(subscriberId: String, consentId: String): Either - - fun rejectConsent(subscriberId: String, consentId: String): Either - - fun reportAnalytics(subscriberId: String, events: String): Either - - fun storeApplicationToken(msisdn: String, applicationToken: ApplicationToken): Either - - fun getReferrals(subscriberId: String): Either> - - fun getReferredBy(subscriberId: String): Either - - fun createSource(subscriberId: String, sourceId: String): Either - - fun setDefaultSource(subscriberId: String, sourceId: String): Either - - fun listSources(subscriberId: String): Either> - - fun removeSource(subscriberId: String, sourceId: String): Either - - companion object { - - /** - * Profile is only valid when name and email set. - */ - fun isValidProfile(profile: Subscriber?): Boolean { - return (profile != null - && !profile.name.isEmpty() - && !profile.email.isEmpty() - && !profile.country.isEmpty()) - } - - /** - * The application token is only valid if token, - * applicationID and token type is set. - */ - fun isValidApplicationToken(appToken: ApplicationToken?): Boolean { - return (appToken != null - && !appToken.token.isEmpty() - && !appToken.applicationID.isEmpty() - && !appToken.tokenType.isEmpty()) - } - } - - @Deprecated(message = "use purchaseProduct") - fun purchaseProductWithoutPayment(subscriberId: String, sku: String): Either - - fun getActivePseudonymOfMsisdnForSubscriber(subscriberId: String): Either -} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/store/SubscriberDAOImpl.kt b/client-api/src/main/kotlin/org/ostelco/prime/client/api/store/SubscriberDAOImpl.kt deleted file mode 100644 index d6ce88aaa..000000000 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/store/SubscriberDAOImpl.kt +++ /dev/null @@ -1,362 +0,0 @@ -package org.ostelco.prime.client.api.store - -import arrow.core.Either -import arrow.core.flatMap -import org.ostelco.prime.analytics.AnalyticsService -import org.ostelco.prime.apierror.ApiError -import org.ostelco.prime.apierror.ApiErrorCode -import org.ostelco.prime.apierror.ApiErrorMapper.mapPaymentErrorToApiError -import org.ostelco.prime.apierror.ApiErrorMapper.mapStorageErrorToApiError -import org.ostelco.prime.apierror.BadGatewayError -import org.ostelco.prime.apierror.BadRequestError -import org.ostelco.prime.apierror.InsufficientStorageError -import org.ostelco.prime.apierror.NotFoundError -import org.ostelco.prime.client.api.metrics.updateMetricsOnNewSubscriber -import org.ostelco.prime.client.api.model.Consent -import org.ostelco.prime.client.api.model.Person -import org.ostelco.prime.client.api.model.SubscriptionStatus -import org.ostelco.prime.getLogger -import org.ostelco.prime.model.ActivePseudonyms -import org.ostelco.prime.model.ApplicationToken -import org.ostelco.prime.model.Bundle -import org.ostelco.prime.model.Product -import org.ostelco.prime.model.PurchaseRecord -import org.ostelco.prime.model.Subscriber -import org.ostelco.prime.model.Subscription -import org.ostelco.prime.module.getResource -import org.ostelco.prime.ocs.OcsSubscriberService -import org.ostelco.prime.paymentprocessor.PaymentProcessor -import org.ostelco.prime.paymentprocessor.core.ProductInfo -import org.ostelco.prime.paymentprocessor.core.ProfileInfo -import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo -import org.ostelco.prime.paymentprocessor.core.SourceInfo -import org.ostelco.prime.pseudonymizer.PseudonymizerService -import org.ostelco.prime.storage.ClientDataSource -import org.ostelco.prime.storage.StoreError -import java.time.Instant -import java.util.* -import java.util.concurrent.ConcurrentHashMap - -/** - * - */ -class SubscriberDAOImpl(private val storage: ClientDataSource, private val ocsSubscriberService: OcsSubscriberService) : SubscriberDAO { - - private val logger by getLogger() - - private val paymentProcessor by lazy { getResource() } - private val pseudonymizer by lazy { getResource() } - private val analyticsReporter by lazy { getResource() } - - /* Table for 'profiles'. */ - private val consentMap = ConcurrentHashMap>() - - override fun getProfile(subscriberId: String): Either { - return try { - storage.getSubscriber(subscriberId).mapLeft { - NotFoundError("Failed to fetch profile.", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_PROFILE, it) - } - } catch (e: Exception) { - logger.error("Failed to fetch profile for subscriberId $subscriberId", e) - Either.left(NotFoundError("Failed to fetch profile", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_PROFILE)) - } - } - - override fun createProfile(subscriberId: String, profile: Subscriber, referredBy: String?): Either { - if (!SubscriberDAO.isValidProfile(profile)) { - logger.error("Failed to create profile. Invalid profile.") - return Either.left(BadRequestError("Incomplete profile description. Profile must contain name and email", ApiErrorCode.FAILED_TO_CREATE_PROFILE)) - } - return try { - storage.addSubscriber(profile, referredBy) - .mapLeft { - mapStorageErrorToApiError("Failed to create profile.", ApiErrorCode.FAILED_TO_CREATE_PROFILE, it) - } - .flatMap { - updateMetricsOnNewSubscriber() - getProfile(subscriberId) - } - } catch (e: Exception) { - logger.error("Failed to create profile for subscriberId $subscriberId", e) - Either.left(BadGatewayError("Failed to create profile", ApiErrorCode.FAILED_TO_CREATE_PROFILE)) - } - } - - override fun storeApplicationToken(msisdn: String, applicationToken: ApplicationToken): Either { - - if (!SubscriberDAO.isValidApplicationToken(applicationToken)) { - return Either.left(BadRequestError("Incomplete ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) - } - - try { - storage.addNotificationToken(msisdn, applicationToken) - } catch (e: Exception) { - logger.error("Failed to store ApplicationToken for msisdn $msisdn", e) - return Either.left(InsufficientStorageError("Failed to store ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) - } - return getNotificationToken(msisdn, applicationToken.applicationID) - } - - private fun getNotificationToken(msisdn: String, applicationId: String): Either { - try { - return storage.getNotificationToken(msisdn, applicationId) - ?.let { Either.right(it) } - ?: return Either.left(NotFoundError("Failed to get ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) - } catch (e: Exception) { - logger.error("Failed to get ApplicationToken for msisdn $msisdn", e) - return Either.left(BadGatewayError("Failed to get ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) - } - } - - override fun updateProfile(subscriberId: String, profile: Subscriber): Either { - if (!SubscriberDAO.isValidProfile(profile)) { - return Either.left(BadRequestError("Incomplete profile description", ApiErrorCode.FAILED_TO_UPDATE_PROFILE)) - } - try { - storage.updateSubscriber(profile) - } catch (e: Exception) { - logger.error("Failed to update profile for subscriberId $subscriberId", e) - return Either.left(BadGatewayError("Failed to update profile", ApiErrorCode.FAILED_TO_UPDATE_PROFILE)) - } - - return getProfile(subscriberId) - } - - override fun getSubscriptionStatus(subscriberId: String): Either { - return try { - storage.getBundles(subscriberId) - .map { bundles -> bundles.firstOrNull()?.balance ?: 0 } - .flatMap { balance -> - storage.getPurchaseRecords(subscriberId) - .map { purchaseRecords -> SubscriptionStatus(balance, purchaseRecords.toList()) } - } - .mapLeft { - mapStorageErrorToApiError("Failed to fetch subscription status.", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTION_STATUS, it) - } - } catch (e: Exception) { - logger.error("Failed to get balance for subscriber $subscriberId", e) - return Either.left(BadGatewayError("Failed to get balance", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTION_STATUS)) - } - } - - override fun getSubscriptions(subscriberId: String): Either> { - try { - return storage.getSubscriptions(subscriberId).mapLeft { - NotFoundError("Failed to get subscriptions.", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS, it) - } - } catch (e: Exception) { - logger.error("Failed to get subscriptions for subscriberId $subscriberId", e) - return Either.left(BadGatewayError("Failed to get subscriptions", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS)) - } - } - - override fun getBundles(subscriberId: String): Either> { - return try { - storage.getBundles(subscriberId).mapLeft { - NotFoundError("Failed to get bundles. ${it.message}", ApiErrorCode.FAILED_TO_FETCH_BUNDLES) - } - } catch (e: Exception) { - logger.error("Failed to get bundles for subscriberId $subscriberId", e) - Either.left(NotFoundError("Failed to get bundles", ApiErrorCode.FAILED_TO_FETCH_BUNDLES)) - } - } - - override fun getActivePseudonymOfMsisdnForSubscriber(subscriberId: String): Either { - return storage.getMsisdn(subscriberId) - .mapLeft { NotFoundError("Failed to get pseudonym for user.", ApiErrorCode.FAILED_TO_FETCH_PSEUDONYM_FOR_SUBSCRIBER, it) } - .map { msisdn -> pseudonymizer.getActivePseudonymsForMsisdn(msisdn) } - } - - override fun getPurchaseHistory(subscriberId: String): Either> { - return try { - return storage.getPurchaseRecords(subscriberId).bimap( - { NotFoundError("Failed to get purchase history.", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_HISTORY, it) }, - { it.toList() }) - } catch (e: Exception) { - logger.error("Failed to get purchase history for subscriberId $subscriberId", e) - Either.left(BadGatewayError("Failed to get purchase history", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_HISTORY)) - } - } - - override fun getMsisdn(subscriberId: String): Either { - return try { - storage.getMsisdn(subscriberId).mapLeft { - NotFoundError("Did not find msisdn for this subscription.", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN, it) - } - } catch (e: Exception) { - logger.error("Did not find msisdn for subscriberId $subscriberId", e) - Either.left(BadGatewayError("Did not find subscription", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) - } - } - - override fun getProducts(subscriberId: String): Either> { - return try { - storage.getProducts(subscriberId).bimap( - { NotFoundError("Failed to fetch products", ApiErrorCode.FAILED_TO_FETCH_PRODUCT_LIST, it) }, - { products -> products.values }) - } catch (e: Exception) { - logger.error("Failed to get Products for subscriberId $subscriberId", e) - Either.left(BadGatewayError("Failed to get Products", ApiErrorCode.FAILED_TO_FETCH_PRODUCT_LIST)) - } - - } - - override fun getProduct(subscriptionId: String, sku: String): Either { - return storage.getProduct(subscriptionId, sku) - .fold({ Either.left(NotFoundError("Failed to get products for sku $sku", ApiErrorCode.FAILED_TO_FETCH_PRODUCT_INFORMATION)) }, - { Either.right(it) }) - } - - @Deprecated("use purchaseProduct", ReplaceWith("purchaseProduct")) - override fun purchaseProductWithoutPayment(subscriberId: String, sku: String): Either { - return getProduct(subscriberId, sku) - // If we can't find the product, return not-found - .mapLeft { NotFoundError("Product unavailable", ApiErrorCode.FAILED_TO_PURCHASE_PRODUCT) } - .flatMap { product -> - val purchaseRecord = PurchaseRecord( - id = UUID.randomUUID().toString(), - product = product, - timestamp = Instant.now().toEpochMilli(), - msisdn = "") - // Create purchase record - storage.addPurchaseRecord(subscriberId, purchaseRecord) - .mapLeft { storeError -> - logger.error("failed to save purchase record, for subscriberId $subscriberId, sku $sku") - BadGatewayError("Failed to store purchase record", ApiErrorCode.FAILED_TO_PURCHASE_PRODUCT, storeError) - } - // Notify OCS - .flatMap { - analyticsReporter.reportPurchaseInfo( - purchaseRecord = purchaseRecord, - subscriberId = subscriberId, - status = "success") - Either.right(Unit) - } - } - .flatMap { - ocsSubscriberService.topup(subscriberId, sku) - .mapLeft { errorReason -> BadGatewayError(description = errorReason, errorCode = ApiErrorCode.FAILED_TO_PURCHASE_PRODUCT) } - } - } - - override fun purchaseProduct( - subscriberId: String, - sku: String, - sourceId: String?, - saveCard: Boolean): Either = - storage.purchaseProduct( - subscriberId, - sku, - sourceId, - saveCard).mapLeft { mapPaymentErrorToApiError("Failed to purchase product. ", ApiErrorCode.FAILED_TO_PURCHASE_PRODUCT, it) } - - override fun getReferrals(subscriberId: String): Either> { - return try { - storage.getReferrals(subscriberId).bimap( - { NotFoundError("Failed to get referral list.", ApiErrorCode.FAILED_TO_FETCH_REFERRALS, it) }, - { list -> list.map { Person(it) } }) - } catch (e: Exception) { - logger.error("Failed to get referral list for subscriberId $subscriberId", e) - Either.left(BadGatewayError("Failed to get referral list", ApiErrorCode.FAILED_TO_FETCH_REFERRALS)) - } - } - - override fun getReferredBy(subscriberId: String): Either { - return try { - storage.getReferredBy(subscriberId).bimap( - { NotFoundError("Failed to get referred-by.", ApiErrorCode.FAILED_TO_FETCH_REFERRED_BY_LIST, it) }, - { Person(name = it) }) - } catch (e: Exception) { - logger.error("Failed to get referred-by for subscriberId $subscriberId", e) - Either.left(BadGatewayError("Failed to get referred-by", ApiErrorCode.FAILED_TO_FETCH_REFERRED_BY_LIST)) - } - } - - override fun getConsents(subscriberId: String): Either> { - consentMap.putIfAbsent(subscriberId, ConcurrentHashMap()) - consentMap[subscriberId]?.putIfAbsent("privacy", false) - return Either.right(listOf(Consent( - consentId = "privacy", - description = "Grant permission to process personal data", - accepted = consentMap[subscriberId]?.get("privacy") ?: false))) - } - - override fun acceptConsent(subscriberId: String, consentId: String): Either { - consentMap.putIfAbsent(subscriberId, ConcurrentHashMap()) - consentMap[subscriberId]?.put(consentId, true) - return Either.right(Consent(consentId, "Grant permission to process personal data", true)) - } - - override fun rejectConsent(subscriberId: String, consentId: String): Either { - consentMap.putIfAbsent(subscriberId, ConcurrentHashMap()) - consentMap[subscriberId]?.put(consentId, false) - return Either.right(Consent(consentId, "Grant permission to process personal data", false)) - } - - private fun getPaymentProfile(name: String): Either = - storage.getPaymentId(name) - ?.let { profileInfoId -> Either.right(ProfileInfo(profileInfoId)) } - ?: Either.left(org.ostelco.prime.storage.NotFoundError("Failed to fetch payment customer ID", name)) - - private fun setPaymentProfile(name: String, profileInfo: ProfileInfo): Either = - Either.cond( - test = storage.createPaymentId(name, profileInfo.id), - ifTrue = { Unit }, - ifFalse = { org.ostelco.prime.storage.NotCreatedError("Failed to store payment customer ID") }) - - override fun reportAnalytics(subscriberId: String, events: String): Either = Either.right(Unit) - - override fun createSource(subscriberId: String, sourceId: String): Either { - return paymentProcessor.getPaymentProfile(subscriberId) - .fold( - { - paymentProcessor.createPaymentProfile(subscriberId) - .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_STORE_PAYMENT_SOURCE, error) } - }, - { profileInfo -> Either.right(profileInfo) } - ) - .flatMap { profileInfo -> - paymentProcessor.addSource(profileInfo.id, sourceId) - .mapLeft { mapPaymentErrorToApiError("Failed to store payment source", ApiErrorCode.FAILED_TO_STORE_PAYMENT_SOURCE, it) } - } - } - - override fun setDefaultSource(subscriberId: String, sourceId: String): Either { - return paymentProcessor.getPaymentProfile(subscriberId) - .fold( - { - paymentProcessor.createPaymentProfile(subscriberId) - .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_SET_DEFAULT_PAYMENT_SOURCE, error) } - }, - { profileInfo -> Either.right(profileInfo) } - ) - .flatMap { profileInfo -> - paymentProcessor.setDefaultSource(profileInfo.id, sourceId) - .mapLeft { mapPaymentErrorToApiError("Failed to set default payment source", ApiErrorCode.FAILED_TO_SET_DEFAULT_PAYMENT_SOURCE, it) } - } - } - - override fun listSources(subscriberId: String): Either> { - return paymentProcessor.getPaymentProfile(subscriberId) - .fold( - { - paymentProcessor.createPaymentProfile(subscriberId) - .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_FETCH_PAYMENT_SOURCES_LIST, error) } - }, - { profileInfo -> Either.right(profileInfo) } - ) - .flatMap { profileInfo -> - paymentProcessor.getSavedSources(profileInfo.id) - .mapLeft { mapPaymentErrorToApiError("Failed to list sources", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_SOURCES_LIST, it) } - } - } - - override fun removeSource(subscriberId: String, sourceId: String): Either { - return paymentProcessor.getPaymentProfile(subscriberId) - .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_REMOVE_PAYMENT_SOURCE, error) } - .flatMap { profileInfo -> - paymentProcessor.removeSource(profileInfo.id, sourceId) - .mapLeft { mapPaymentErrorToApiError("Failed to remove payment source", ApiErrorCode.FAILED_TO_REMOVE_PAYMENT_SOURCE, it) } - } - } -} diff --git a/client-api/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/client-api/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule deleted file mode 100644 index 1691a925f..000000000 --- a/client-api/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule +++ /dev/null @@ -1 +0,0 @@ -org.ostelco.prime.client.api.ClientApiModule \ No newline at end of file diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/AnalyticsResourceTest.kt b/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/AnalyticsResourceTest.kt deleted file mode 100644 index f14cb3aed..000000000 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/AnalyticsResourceTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import arrow.core.Either -import com.fasterxml.jackson.core.JsonParseException -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.nhaarman.mockito_kotlin.argumentCaptor -import io.dropwizard.auth.AuthDynamicFeature -import io.dropwizard.auth.AuthValueFactoryProvider -import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter -import io.dropwizard.testing.junit.ResourceTestRule -import org.assertj.core.api.Assertions.assertThat -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory -import org.junit.Before -import org.junit.ClassRule -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken -import java.io.IOException -import java.util.* -import javax.ws.rs.client.Entity -import javax.ws.rs.core.MediaType -import javax.ws.rs.core.Response - -/** - * Analytics API tests. - * - */ -class AnalyticsResourceTest { - - private val MAPPER = jacksonObjectMapper() - - private val email = "mw@internet.org" - - @Before - fun setUp() { - `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) - } - - @Test - fun reportAnalytics() { - val arg1 = argumentCaptor() - val arg2 = argumentCaptor() - - `when`(DAO.reportAnalytics(arg1.capture(), arg2.capture())).thenReturn(Either.right(Unit)) - - val events = """ - [ - { - "eventType": "PURCHASES_A_PRODUCT", - "sku": "1", - "time": "1524734549" - }, - { - "eventType": "EXITS_APPLICATION", - "time": "1524742549" - } - ]""".trimIndent() - - assertThat(isValidJson(events)).isTrue() - - val resp = RULE.target("/analytics") - .request(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .post(Entity.json(events)) - - assertThat(resp.status).isEqualTo(Response.Status.CREATED.statusCode) - assertThat(resp.mediaType).isNull() - assertThat(arg1.firstValue).isEqualTo(email) - assertThat(isValidJson(events)).isTrue() - assertThat(isValidJson(arg2.firstValue)).isTrue() - } - - /* https://stackoverflow.com/questions/10226897/how-to-validate-json-with-jackson-json */ - private fun isValidJson(json: String): Boolean { - try { - val parser = MAPPER.factory - .createParser(json) - while (parser.nextToken() != null) { - } - return true - } catch (e: JsonParseException) { - /* Ignored. */ - } catch (e: IOException) { - /* Ignored. */ - } - - return false - } - - companion object { - - val DAO: SubscriberDAO = mock(SubscriberDAO::class.java) - val AUTHENTICATOR: OAuthAuthenticator = mock(OAuthAuthenticator::class.java) - - @JvmField - @ClassRule - val RULE: ResourceTestRule = ResourceTestRule.builder() - .addResource(AuthDynamicFeature( - OAuthCredentialAuthFilter.Builder() - .setAuthenticator(AUTHENTICATOR) - .setPrefix("Bearer") - .buildAuthFilter())) - .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) - .addResource(AnalyticsResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) - .build() - } -} diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ConsentsResourceTest.kt b/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ConsentsResourceTest.kt deleted file mode 100644 index fa4a6eb02..000000000 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ConsentsResourceTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import arrow.core.Either -import com.nhaarman.mockito_kotlin.argumentCaptor -import io.dropwizard.auth.AuthDynamicFeature -import io.dropwizard.auth.AuthValueFactoryProvider -import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter -import io.dropwizard.testing.junit.ResourceTestRule -import org.assertj.core.api.Assertions.assertThat -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory -import org.junit.Before -import org.junit.ClassRule -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.model.Consent -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken -import org.ostelco.prime.apierror.ApiErrorCode -import org.ostelco.prime.apierror.NotFoundError -import java.util.* -import javax.ws.rs.client.Entity -import javax.ws.rs.core.GenericType -import javax.ws.rs.core.MediaType -import javax.ws.rs.core.Response - -/** - * Consents API tests. - * - */ -class ConsentsResourceTest { - - private val email = "mw@internet.org" - - private val consents = listOf( - Consent("1", "blabla", false), - Consent("2", "blabla", true)) - - @Before - fun setUp() { - `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) - } - - @Test - fun getConsents() { - val arg = argumentCaptor() - - `when`(DAO.getConsents(arg.capture())).thenReturn(Either.right(consents)) - - val resp = RULE.target("/consents") - .request() - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .get(Response::class.java) - - assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) - assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - assertThat(resp.readEntity(object : GenericType>() { - - })).isEqualTo(consents) - assertThat(arg.firstValue).isEqualTo(email) - } - - @Test - fun acceptConsent() { - val arg1 = argumentCaptor() - val arg2 = argumentCaptor() - - val consentId = consents[0].consentId - - `when`(DAO.acceptConsent(arg1.capture(), arg2.capture())).thenReturn(Either.right(consents[0])) - `when`(DAO.rejectConsent(arg1.capture(), arg2.capture())).thenReturn(Either.left( - NotFoundError("No consents found", ApiErrorCode.FAILED_TO_FETCH_CONSENT))) - - val resp = RULE.target("/consents/$consentId") - .queryParam("accepted", true) - .request() - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .put(Entity.text("")) - - assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) - assertThat(arg1.firstValue).isEqualTo(email) - assertThat(arg2.firstValue).isEqualTo(consentId) - } - - @Test - fun rejectConsent() { - val arg1 = argumentCaptor() - val arg2 = argumentCaptor() - - val consentId = consents[0].consentId - - `when`(DAO.acceptConsent(arg1.capture(), arg2.capture())).thenReturn(Either.left( - NotFoundError("No consents found", ApiErrorCode.FAILED_TO_FETCH_CONSENT))) - `when`(DAO.rejectConsent(arg1.capture(), arg2.capture())).thenReturn(Either.right(consents[0])) - - val resp = RULE.target("/consents/$consentId") - .queryParam("accepted", false) - .request() - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .put(Entity.text("")) - - assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) - assertThat(arg1.firstValue).isEqualTo(email) - assertThat(arg2.firstValue).isEqualTo(consentId) - } - - companion object { - - val DAO: SubscriberDAO = mock(SubscriberDAO::class.java) - val AUTHENTICATOR: OAuthAuthenticator = mock(OAuthAuthenticator::class.java) - - @JvmField - @ClassRule - val RULE: ResourceTestRule = ResourceTestRule.builder() - .addResource(AuthDynamicFeature( - OAuthCredentialAuthFilter.Builder() - .setAuthenticator(AUTHENTICATOR) - .setPrefix("Bearer") - .buildAuthFilter())) - .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) - .addResource(ConsentsResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) - .build() - } -} diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ProfileResourceTest.kt b/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ProfileResourceTest.kt deleted file mode 100644 index bbc5ad684..000000000 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ProfileResourceTest.kt +++ /dev/null @@ -1,193 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import arrow.core.Either -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.nhaarman.mockito_kotlin.argumentCaptor -import io.dropwizard.auth.AuthDynamicFeature -import io.dropwizard.auth.AuthValueFactoryProvider -import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter -import io.dropwizard.testing.junit.ResourceTestRule -import org.assertj.core.api.Assertions.assertThat -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory -import org.junit.Before -import org.junit.ClassRule -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken -import org.ostelco.prime.model.Subscriber -import java.util.* -import javax.ws.rs.client.Entity -import javax.ws.rs.core.MediaType -import javax.ws.rs.core.Response - -/** - * Profile API tests. - * - */ -class ProfileResourceTest { - - private val email = "boaty@internet.org" - private val name = "Boaty McBoatface" - private val address = "Storvej 10" - private val postCode = "132 23" - private val city = "Oslo" - - private val profile = Subscriber(email) - - @Before - fun setUp() { - `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) - } - - @Test - fun getProfile() { - val arg = argumentCaptor() - - `when`(DAO.getProfile(arg.capture())).thenReturn(Either.right(profile)) - - val resp = RULE.target("/profile") - .request() - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .get(Response::class.java) - - assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) - assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - - assertThat(resp.readEntity(Subscriber::class.java)).isEqualTo(profile) - assertThat(arg.firstValue).isEqualTo(email) - } - - @Test - fun createProfile() { - val arg1 = argumentCaptor() - val arg2 = argumentCaptor() - val arg3 = argumentCaptor() - - - `when`(DAO.createProfile(arg1.capture(), arg2.capture(), arg3.capture())) - .thenReturn(Either.right(profile)) - - val resp = RULE.target("/profile") - .request(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .post(Entity.json("""{ - "name": "$name", - "address": "$address", - "postCode": "$postCode", - "city": "$city", - "email": "$email" - }""".trimIndent())) - - assertThat(resp.status).isEqualTo(Response.Status.CREATED.statusCode) - assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - assertThat(arg1.firstValue).isEqualTo(email) - assertThat(arg2.firstValue.email).isEqualTo(email) - assertThat(arg2.firstValue.name).isEqualTo(name) - assertThat(arg2.firstValue.address).isEqualTo(address) - assertThat(arg2.firstValue.postCode).isEqualTo(postCode) - assertThat(arg2.firstValue.city).isEqualTo(city) - assertThat(arg3.firstValue).isNull() - } - - @Test - fun createProfileWithReferral() { - val arg1 = argumentCaptor() - val arg2 = argumentCaptor() - val arg3 = argumentCaptor() - - val referredBy = "foo@bar.com" - - `when`(DAO.createProfile(arg1.capture(), arg2.capture(), arg3.capture())) - .thenReturn(Either.right(profile)) - - val resp = RULE.target("/profile") - .queryParam("referred_by", referredBy) - .request(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .post(Entity.json("""{ - "name": "$name", - "address": "$address", - "postCode": "$postCode", - "city": "$city", - "email": "$email" - }""".trimIndent())) - - assertThat(resp.status).isEqualTo(Response.Status.CREATED.statusCode) - assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - assertThat(arg1.firstValue).isEqualTo(email) - assertThat(arg2.firstValue.email).isEqualTo(email) - assertThat(arg2.firstValue.name).isEqualTo(name) - assertThat(arg2.firstValue.address).isEqualTo(address) - assertThat(arg2.firstValue.postCode).isEqualTo(postCode) - assertThat(arg2.firstValue.city).isEqualTo(city) - assertThat(arg3.firstValue).isEqualTo(referredBy) - } - - @Test - fun updateProfile() { - val arg1 = argumentCaptor() - val arg2 = argumentCaptor() - - val newAddress = "Storvej 10" - val newPostCode = "132 23" - - `when`(DAO.updateProfile(arg1.capture(), arg2.capture())) - .thenReturn(Either.right(profile)) - - val resp = RULE.target("/profile") - .request(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .put(Entity.json("""{ - "name": "$name", - "address": "$newAddress", - "postCode": "$newPostCode", - "city": "$city", - "email": "$email" - }""".trimIndent())) - - assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) - assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - assertThat(arg1.firstValue).isEqualTo(email) - assertThat(arg2.firstValue.email).isEqualTo(email) - assertThat(arg2.firstValue.name).isEqualTo(name) - assertThat(arg2.firstValue.address).isEqualTo(newAddress) - assertThat(arg2.firstValue.postCode).isEqualTo(newPostCode) - assertThat(arg2.firstValue.city).isEqualTo(city) - } - - @Test - fun updateWithIncompleteProfile() { - val resp = RULE.target("/profile") - .request(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .put(Entity.json("""{ "name": "$name" }""")) - - assertThat(resp.status).isEqualTo(Response.Status.BAD_REQUEST.statusCode) - } - - companion object { - - val DAO = mock(SubscriberDAO::class.java) - val AUTHENTICATOR = mock(OAuthAuthenticator::class.java) - - @JvmField - @ClassRule - val RULE = ResourceTestRule.builder() - .setMapper(jacksonObjectMapper()) - .addResource(AuthDynamicFeature( - OAuthCredentialAuthFilter.Builder() - .setAuthenticator(AUTHENTICATOR) - .setPrefix("Bearer") - .buildAuthFilter())) - .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) - .addResource(ProfileResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) - .build() - } -} diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/SubscriptionResourceTest.kt b/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/SubscriptionResourceTest.kt deleted file mode 100644 index 67b0c66b1..000000000 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/SubscriptionResourceTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.ostelco.prime.client.api.resources - -import arrow.core.Either -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.nhaarman.mockito_kotlin.argumentCaptor -import io.dropwizard.auth.AuthDynamicFeature -import io.dropwizard.auth.AuthValueFactoryProvider -import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter -import io.dropwizard.testing.junit.ResourceTestRule -import org.assertj.core.api.Assertions.assertThat -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.ClassRule -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.model.SubscriptionStatus -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken -import org.ostelco.prime.model.ActivePseudonyms -import org.ostelco.prime.model.Price -import org.ostelco.prime.model.Product -import org.ostelco.prime.model.PseudonymEntity -import org.ostelco.prime.model.PurchaseRecord -import java.time.Instant -import java.util.* -import javax.ws.rs.core.MediaType -import javax.ws.rs.core.Response - -/** - * Subscription API tests. - * - */ -class SubscriptionResourceTest { - - private val email = "mw@internet.org" - - private val purchaseRecords = listOf( - PurchaseRecord( - product = Product(sku = "1", price = Price(10, "NOK")), - timestamp = Instant.now().toEpochMilli(), - id = UUID.randomUUID().toString(), - msisdn = "")) - - @Before - fun setUp() { - `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) - } - - @Test - fun getSubscriptionStatus() { - val subscriptionStatus = SubscriptionStatus(5, purchaseRecords) - val arg = argumentCaptor() - - `when`(DAO.getSubscriptionStatus(arg.capture())).thenReturn(Either.right(subscriptionStatus)) - - val resp = RULE.target("/subscription/status") - .request() - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .get(Response::class.java) - - assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) - assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - - // assertThat and assertEquals is not working - assertTrue(subscriptionStatus == resp.readEntity(SubscriptionStatus::class.java)) - assertThat(arg.firstValue).isEqualTo(email) - } - - @Test - fun getActivePseudonyms() { - val arg = argumentCaptor() - - val msisdn = "4790300001" - val pseudonym = PseudonymEntity(msisdn, "random", 0, 1) - val activePseudonyms = ActivePseudonyms(pseudonym, pseudonym) - - `when`(DAO.getActivePseudonymOfMsisdnForSubscriber(arg.capture())) - .thenReturn(Either.right(activePseudonyms)) - - val responseJsonString = jacksonObjectMapper().writeValueAsString(activePseudonyms) - - val resp = RULE.target("/subscription/activePseudonyms") - .request() - .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") - .get(Response::class.java) - - assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) - assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - assertTrue(resp.hasEntity()) - assertTrue(responseJsonString == resp.readEntity(String::class.java)) - } - - companion object { - - val DAO: SubscriberDAO = mock(SubscriberDAO::class.java) - val AUTHENTICATOR: OAuthAuthenticator = mock(OAuthAuthenticator::class.java) - - @JvmField - @ClassRule - val RULE: ResourceTestRule = ResourceTestRule.builder() - .setMapper(jacksonObjectMapper()) - .addResource(AuthDynamicFeature( - OAuthCredentialAuthFilter.Builder() - .setAuthenticator(AUTHENTICATOR) - .setPrefix("Bearer") - .buildAuthFilter())) - .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) - .addResource(SubscriptionResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) - .build() - } -} diff --git a/client-api/.gitignore b/customer-endpoint/.gitignore similarity index 100% rename from client-api/.gitignore rename to customer-endpoint/.gitignore diff --git a/client-api/README.md b/customer-endpoint/README.md similarity index 100% rename from client-api/README.md rename to customer-endpoint/README.md diff --git a/customer-endpoint/build.gradle b/customer-endpoint/build.gradle new file mode 100644 index 000000000..fb3bf456e --- /dev/null +++ b/customer-endpoint/build.gradle @@ -0,0 +1,23 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "java-library" +} + +dependencies { + implementation project(":prime-modules") + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.assertj:assertj-core:$assertJVersion" + + testImplementation "io.jsonwebtoken:jjwt:$jjwtVersion" + testImplementation "com.nhaarman:mockito-kotlin:1.6.0" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/CustomerEndpointModule.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/CustomerEndpointModule.kt new file mode 100644 index 000000000..58744473b --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/CustomerEndpointModule.kt @@ -0,0 +1,57 @@ +package org.ostelco.prime.customer.endpoint + +import com.fasterxml.jackson.annotation.JsonTypeName +import io.dropwizard.setup.Environment +import org.eclipse.jetty.servlets.CrossOriginFilter +import org.ostelco.prime.customer.endpoint.metrics.reportMetricsAtStartUp +import org.ostelco.prime.customer.endpoint.resources.ApplicationTokenResource +import org.ostelco.prime.customer.endpoint.resources.BundlesResource +import org.ostelco.prime.customer.endpoint.resources.ContextResource +import org.ostelco.prime.customer.endpoint.resources.CustomerResource +import org.ostelco.prime.customer.endpoint.resources.PaymentSourcesResource +import org.ostelco.prime.customer.endpoint.resources.ProductsResource +import org.ostelco.prime.customer.endpoint.resources.PurchaseResource +import org.ostelco.prime.customer.endpoint.resources.ReferralResource +import org.ostelco.prime.customer.endpoint.resources.RegionsResource +import org.ostelco.prime.customer.endpoint.store.SubscriberDAOImpl +import org.ostelco.prime.module.PrimeModule +import java.util.* +import javax.servlet.DispatcherType + + +/** + * Provides API for client. + * + */ +@JsonTypeName("api") +class CustomerEndpointModule : PrimeModule { + + override fun init(env: Environment) { + + // Allow CORS + val corsFilterRegistration = env.servlets().addFilter("CORS", CrossOriginFilter::class.java) + // Configure CORS parameters + corsFilterRegistration.setInitParameter("allowedOrigins", "*") + corsFilterRegistration.setInitParameter("allowedHeaders", + "Cache-Control,If-Modified-Since,Pragma,Content-Type,Authorization,X-Requested-With,Content-Length,Accept,Origin") + corsFilterRegistration.setInitParameter("allowedMethods", "OPTIONS,GET,PUT,POST,DELETE,HEAD") + corsFilterRegistration.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType::class.java), true, "/*") + + + val dao = SubscriberDAOImpl() + val jerseyEnv = env.jersey() + + /* APIs. */ + jerseyEnv.register(ProductsResource(dao)) + jerseyEnv.register(PurchaseResource(dao)) + jerseyEnv.register(ReferralResource(dao)) + jerseyEnv.register(PaymentSourcesResource(dao)) + jerseyEnv.register(BundlesResource(dao)) + jerseyEnv.register(RegionsResource(dao)) + jerseyEnv.register(CustomerResource(dao)) + jerseyEnv.register(ContextResource(dao)) + jerseyEnv.register(ApplicationTokenResource(dao)) + + reportMetricsAtStartUp() + } +} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/metrics/Metrics.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/metrics/Metrics.kt similarity index 66% rename from client-api/src/main/kotlin/org/ostelco/prime/client/api/metrics/Metrics.kt rename to customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/metrics/Metrics.kt index e61d240a9..4e7189287 100644 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/metrics/Metrics.kt +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/metrics/Metrics.kt @@ -1,4 +1,4 @@ -package org.ostelco.prime.client.api.metrics +package org.ostelco.prime.customer.endpoint.metrics import org.ostelco.prime.analytics.AnalyticsService import org.ostelco.prime.analytics.PrimeMetric.TOTAL_USERS @@ -10,11 +10,11 @@ val analyticsService: AnalyticsService = getResource() val adminStore: AdminDataSource = getResource() fun reportMetricsAtStartUp() { - analyticsService.reportMetric(TOTAL_USERS, adminStore.getSubscriberCount()) - analyticsService.reportMetric(USERS_ACQUIRED_THROUGH_REFERRALS, adminStore.getReferredSubscriberCount()) + analyticsService.reportMetric(TOTAL_USERS, adminStore.getCustomerCount()) + analyticsService.reportMetric(USERS_ACQUIRED_THROUGH_REFERRALS, adminStore.getReferredCustomerCount()) } fun updateMetricsOnNewSubscriber() { - analyticsService.reportMetric(TOTAL_USERS, adminStore.getSubscriberCount()) - analyticsService.reportMetric(USERS_ACQUIRED_THROUGH_REFERRALS, adminStore.getReferredSubscriberCount()) + analyticsService.reportMetric(TOTAL_USERS, adminStore.getCustomerCount()) + analyticsService.reportMetric(USERS_ACQUIRED_THROUGH_REFERRALS, adminStore.getReferredCustomerCount()) } \ No newline at end of file diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/model/Model.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/model/Model.kt new file mode 100644 index 000000000..7dc52912e --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/model/Model.kt @@ -0,0 +1,3 @@ +package org.ostelco.prime.customer.endpoint.model + +data class Person(var name:String? = null) diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ApplicationTokenResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ApplicationTokenResource.kt new file mode 100644 index 000000000..7eed2eca5 --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ApplicationTokenResource.kt @@ -0,0 +1,44 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.ApplicationToken +import org.ostelco.prime.model.Identity +import javax.validation.constraints.NotNull +import javax.ws.rs.Consumes +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * ApplicationToken API. + * + */ +@Path("/applicationToken") +class ApplicationTokenResource(private val dao: SubscriberDAO) { + + @POST + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + fun storeApplicationToken(@Auth authToken: AccessTokenPrincipal?, + @NotNull applicationToken: ApplicationToken): Response { + if (authToken == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getCustomer(identity = Identity(id = authToken.name, type = "EMAIL", provider = authToken.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { customer -> + dao.storeApplicationToken(customer.id, applicationToken).fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.CREATED).entity(asJson(it)) }) + }) + .build() + } +} diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/BundlesResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/BundlesResource.kt new file mode 100644 index 000000000..52b04a1e9 --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/BundlesResource.kt @@ -0,0 +1,32 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +@Path("/bundles") +class BundlesResource(private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getBundles(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getBundles( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } +} \ No newline at end of file diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ContextResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ContextResource.kt new file mode 100644 index 000000000..9d11bfa6e --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ContextResource.kt @@ -0,0 +1,33 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +@Path("/context") +class ContextResource(private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getBundles(@Auth token: AccessTokenPrincipal?): Response { + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getContext( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } +} \ No newline at end of file diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/CustomerResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/CustomerResource.kt new file mode 100644 index 000000000..44480359b --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/CustomerResource.kt @@ -0,0 +1,123 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity +import java.util.* +import javax.validation.constraints.NotNull +import javax.ws.rs.Consumes +import javax.ws.rs.DELETE +import javax.ws.rs.GET +import javax.ws.rs.POST +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +@Path("/customer") +class CustomerResource(private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getCustomer(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getCustomer(identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + @POST + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + fun createCustomer(@Auth token: AccessTokenPrincipal?, + @NotNull @QueryParam("nickname") nickname: String, + @NotNull @QueryParam("contactEmail") contactEmail: String, + @QueryParam("referredBy") referredBy: String?): Response { + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.createCustomer( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + customer = Customer( + id = UUID.randomUUID().toString(), + nickname = nickname, + contactEmail = contactEmail, + analyticsId = UUID.randomUUID().toString(), + referralId = UUID.randomUUID().toString()), + referredBy = referredBy) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.CREATED).entity(asJson(it)) }) + .build() + } + + @PUT + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + fun updateCustomer(@Auth token: AccessTokenPrincipal?, + @QueryParam("nickname") nickname: String?, + @QueryParam("contactEmail") contactEmail: String?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.updateCustomer( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + nickname = nickname, + contactEmail = contactEmail) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + @DELETE + @Produces(MediaType.APPLICATION_JSON) + fun removeCustomer(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.removeCustomer(identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.NO_CONTENT).entity(asJson("")) }) + .build() + } + + @GET + @Path("stripe-ephemeral-key") + @Produces(MediaType.APPLICATION_JSON) + fun getStripeEphemeralKey( + @Auth token: AccessTokenPrincipal?, + @QueryParam("api_version") apiVersion: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getStripeEphemeralKey( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + apiVersion = apiVersion) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { stripeEphemeralKey -> Response.status(Response.Status.OK).entity(stripeEphemeralKey) }) + .build() + } +} \ No newline at end of file diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/KycResources.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/KycResources.kt new file mode 100644 index 000000000..eb8f3ebaf --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/KycResources.kt @@ -0,0 +1,165 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.getLogger +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.validation.constraints.NotNull +import javax.ws.rs.GET +import javax.ws.rs.POST +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.PathParam +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * Generic [KycResource] which has eKYC API common for all the countries. + * + * If there are any country specific API, then have a country specific KYC Resource which will inherit common APIs and + * add country specific APIs. + * + * For now we have simple case, which can be handled by inheritance. But, if it starts to get messy, then replace it + * with Strategy pattern and use composition instead of inheritance. + * + */ +open class KycResource(private val regionCode: String, private val dao: SubscriberDAO) { + + @Path("/jumio") + fun jumioResource(): JumioKycResource { + return JumioKycResource(regionCode = regionCode, dao = dao) + } +} + +/** + * [SingaporeKycResource] uses [JumioKycResource] via parent class [KycResource]. + * It has Singapore specific eKYC APIs. + * + */ +class SingaporeKycResource(private val dao: SubscriberDAO): KycResource(regionCode = "sg", dao = dao) { + private val logger by getLogger() + + @GET + @Path("/myInfo/{authorisationCode}") + @Produces(MediaType.APPLICATION_JSON) + fun getCustomerMyInfoData( + @Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("authorisationCode") + authorisationCode: String): Response { + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getCustomerMyInfoData( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + authorisationCode = authorisationCode) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { personalData -> Response.status(Response.Status.OK).entity(personalData) }) + .build() + } + + @GET + @Path("/dave/{nricFinId}") + @Produces(MediaType.APPLICATION_JSON) + fun checkNricFinId( + @Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("nricFinId") + nricFinId: String): Response { + + logger.info("checkNricFinId for ${nricFinId}") + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.checkNricFinIdUsingDave( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + nricFinId = nricFinId) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { personalData -> Response.status(Response.Status.OK).entity(personalData) }) + .build() + } + + @PUT + @Path("/profile") + @Produces(MediaType.APPLICATION_JSON) + fun saveProfile( + @Auth token: AccessTokenPrincipal?, + @NotNull + @QueryParam("address") + address: String, + @NotNull + @QueryParam("phoneNumber") + phoneNumber: String): Response { + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.saveAddressAndPhoneNumber( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + address = address, + phoneNumber = phoneNumber) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.NO_CONTENT) }) + .build() + } +} + +class JumioKycResource(private val regionCode: String, private val dao: SubscriberDAO) { + + @POST + @Path("/scans") + @Produces(MediaType.APPLICATION_JSON) + fun newEKYCScanId( + @Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.createNewJumioKycScanId( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + regionCode = regionCode) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { scanInformation -> Response.status(Response.Status.CREATED).entity(scanInformation) }) + .build() + } + + @GET + @Path("/scans/{scanId}") + @Produces(MediaType.APPLICATION_JSON) + fun getScanStatus( + @Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("scanId") + scanId: String + ): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getScanInformation( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + scanId = scanId) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { scanInformation -> Response.status(Response.Status.OK).entity(scanInformation) }) + .build() + } +} \ No newline at end of file diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/PaymentResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/PaymentSourcesResource.kt similarity index 58% rename from client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/PaymentResource.kt rename to customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/PaymentSourcesResource.kt index ce37f15e8..2459a01c0 100644 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/PaymentResource.kt +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/PaymentSourcesResource.kt @@ -1,12 +1,19 @@ -package org.ostelco.prime.client.api.resources +package org.ostelco.prime.customer.endpoint.resources import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.getLogger +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity import javax.validation.constraints.NotNull -import javax.ws.rs.* +import javax.ws.rs.DELETE +import javax.ws.rs.GET +import javax.ws.rs.POST +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response /** @@ -14,12 +21,10 @@ import javax.ws.rs.core.Response * */ @Path("/paymentSources") -class PaymentResource(private val dao: SubscriberDAO) { - - private val logger by getLogger() +class PaymentSourcesResource(private val dao: SubscriberDAO) { @POST - @Produces("application/json") + @Produces(MediaType.APPLICATION_JSON) fun createSource(@Auth token: AccessTokenPrincipal?, @NotNull @QueryParam("sourceId") @@ -29,30 +34,33 @@ class PaymentResource(private val dao: SubscriberDAO) { .build() } - return dao.createSource(token.name, sourceId) + return dao.createSource( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + sourceId = sourceId) .fold( { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { sourceInfo -> Response.status(Response.Status.CREATED).entity(sourceInfo)} - ).build() + { sourceInfo -> Response.status(Response.Status.CREATED).entity(sourceInfo) }) + .build() } @GET - @Produces("application/json") + @Produces(MediaType.APPLICATION_JSON) fun listSources(@Auth token: AccessTokenPrincipal?): Response { if (token == null) { return Response.status(Response.Status.UNAUTHORIZED) .build() } - return dao.listSources(token.name) + return dao.listSources( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) .fold( { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { sourceList -> Response.status(Response.Status.OK).entity(sourceList)} - ).build() + { sourceList -> Response.status(Response.Status.OK).entity(sourceList) }) + .build() } @PUT - @Produces("application/json") + @Produces(MediaType.APPLICATION_JSON) fun setDefaultSource(@Auth token: AccessTokenPrincipal?, @NotNull @QueryParam("sourceId") @@ -62,15 +70,17 @@ class PaymentResource(private val dao: SubscriberDAO) { .build() } - return dao.setDefaultSource(token.name, sourceId) + return dao.setDefaultSource( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + sourceId = sourceId) .fold( { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { sourceInfo -> Response.status(Response.Status.OK).entity(sourceInfo)} - ).build() + { sourceInfo -> Response.status(Response.Status.OK).entity(sourceInfo) }) + .build() } @DELETE - @Produces("application/json") + @Produces(MediaType.APPLICATION_JSON) fun removeSource(@Auth token: AccessTokenPrincipal?, @NotNull @QueryParam("sourceId") @@ -80,10 +90,12 @@ class PaymentResource(private val dao: SubscriberDAO) { .build() } - return dao.removeSource(token.name, sourceId) + return dao.removeSource( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + sourceId = sourceId) .fold( { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { sourceInfo -> Response.status(Response.Status.OK).entity(sourceInfo)} - ).build() + { sourceInfo -> Response.status(Response.Status.OK).entity(sourceInfo) }) + .build() } } diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ProductsResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResource.kt similarity index 53% rename from client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ProductsResource.kt rename to customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResource.kt index 71e224489..e9ceb851f 100644 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/resources/ProductsResource.kt +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResource.kt @@ -1,9 +1,10 @@ -package org.ostelco.prime.client.api.resources +package org.ostelco.prime.customer.endpoint.resources import io.dropwizard.auth.Auth -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.store.SubscriberDAO +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity import javax.validation.constraints.NotNull import javax.ws.rs.GET import javax.ws.rs.POST @@ -11,6 +12,7 @@ import javax.ws.rs.Path import javax.ws.rs.PathParam import javax.ws.rs.Produces import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response import javax.ws.rs.core.Response.Status.CREATED @@ -22,42 +24,23 @@ import javax.ws.rs.core.Response.Status.CREATED class ProductsResource(private val dao: SubscriberDAO) { @GET - @Produces("application/json") + @Produces(MediaType.APPLICATION_JSON) fun getProducts(@Auth token: AccessTokenPrincipal?): Response { if (token == null) { return Response.status(Response.Status.UNAUTHORIZED) .build() } - return dao.getProducts(token.name).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() - } - - @Deprecated("use purchaseProduct") - @POST - @Path("{sku}") - @Produces("application/json") - fun purchaseProductWithoutPayment(@Auth token: AccessTokenPrincipal?, - @NotNull - @PathParam("sku") - sku: String): Response { - if (token == null) { - return Response.status(Response.Status.UNAUTHORIZED) - .build() - } - - return dao.purchaseProductWithoutPayment(token.name, sku) + return dao.getProducts(identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) .fold( { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { productInfo -> Response.status(CREATED).entity(productInfo) } - ).build() + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() } @POST @Path("{sku}/purchase") - @Produces("application/json") + @Produces(MediaType.APPLICATION_JSON) fun purchaseProduct(@Auth token: AccessTokenPrincipal?, @NotNull @PathParam("sku") @@ -65,16 +48,21 @@ class ProductsResource(private val dao: SubscriberDAO) { @QueryParam("sourceId") sourceId: String?, @QueryParam("saveCard") - saveCard: Boolean = false): Response { /* 'false' is default. */ + saveCard: Boolean?): Response { /* 'false' is default. */ + if (token == null) { return Response.status(Response.Status.UNAUTHORIZED) .build() } - return dao.purchaseProduct(token.name, sku, sourceId, saveCard) + return dao.purchaseProduct( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + sku = sku, + sourceId = sourceId, + saveCard = saveCard ?: false) .fold( { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { productInfo -> Response.status(CREATED).entity(productInfo) } - ).build() + { productInfo -> Response.status(CREATED).entity(productInfo) }) + .build() } } diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/PurchaseResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/PurchaseResource.kt new file mode 100644 index 000000000..65206c78f --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/PurchaseResource.kt @@ -0,0 +1,36 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * Purchase API. + * + */ +@Path("/purchases") +class PurchaseResource(private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getPurchases(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getPurchaseHistory( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } +} diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ReferralResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ReferralResource.kt new file mode 100644 index 000000000..4e37f03df --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ReferralResource.kt @@ -0,0 +1,47 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +@Path("referred") +class ReferralResource(private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getReferrals(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED).build() + } + + return dao.getReferrals( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(it) }) + .build() + } + + @GET + @Path("/by") + @Produces(MediaType.APPLICATION_JSON) + fun getReferredBy(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED).build() + } + + return dao.getReferredBy( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(it) }) + .build() + } +} diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/RegionsResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/RegionsResource.kt new file mode 100644 index 000000000..6b06482a0 --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/RegionsResource.kt @@ -0,0 +1,79 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.validation.constraints.NotNull +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.PathParam +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +@Path("/regions") +class RegionsResource(private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getRegions(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getRegions(identity = Identity(id = token.name, type = "EMAIL", provider = token.provider)) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + @GET + @Path("/{regionCode}") + @Produces(MediaType.APPLICATION_JSON) + fun getRegion( + @Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("regionCode") + regionCode: String): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getRegion( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + regionCode = regionCode) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + @Path("/{regionCode}/kyc") + fun kycResource( + @NotNull + @PathParam("regionCode") + regionCode: String + ): KycResource = when (regionCode.toLowerCase()) { + "sg" -> SingaporeKycResource(dao = dao) + else -> KycResource(regionCode = regionCode, dao = dao) + } + + @Path("/{regionCode}/subscriptions") + fun subscriptionsResource( + @NotNull + @PathParam("regionCode") + regionCode: String + ): SubscriptionsResource = SubscriptionsResource(regionCode = regionCode, dao = dao) + + @Path("/{regionCode}/simProfiles") + fun simProfilesResource( + @NotNull + @PathParam("regionCode") + regionCode: String + ): SimProfilesResource = SimProfilesResource(regionCode = regionCode, dao = dao) +} \ No newline at end of file diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/SimProfilesResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/SimProfilesResource.kt new file mode 100644 index 000000000..c001d5066 --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/SimProfilesResource.kt @@ -0,0 +1,54 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.ws.rs.GET +import javax.ws.rs.POST +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +class SimProfilesResource(private val regionCode: String, private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getSimProfiles(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getSimProfiles( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + regionCode = regionCode) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } + + @POST + @Produces(MediaType.APPLICATION_JSON) + fun provisionSimProfile( + @Auth token: AccessTokenPrincipal?, + @QueryParam("profileType") profileType: String?): Response { + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.provisionSimProfile( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + regionCode = regionCode, + profileType = profileType) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } +} diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/SubscriptionsResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/SubscriptionsResource.kt new file mode 100644 index 000000000..a2c2ebd1e --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/SubscriptionsResource.kt @@ -0,0 +1,36 @@ +package org.ostelco.prime.customer.endpoint.resources + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.model.Identity +import javax.ws.rs.GET +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * Subscriptions API. + * + */ + +class SubscriptionsResource(private val regionCode: String, private val dao: SubscriberDAO) { + + @GET + @Produces(MediaType.APPLICATION_JSON) + fun getSubscriptions(@Auth token: AccessTokenPrincipal?): Response { + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .build() + } + + return dao.getSubscriptions( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + regionCode = regionCode) + .fold( + { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, + { Response.status(Response.Status.OK).entity(asJson(it)) }) + .build() + } +} diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAO.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAO.kt new file mode 100644 index 000000000..87ec445d1 --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAO.kt @@ -0,0 +1,139 @@ +package org.ostelco.prime.customer.endpoint.store + +import arrow.core.Either +import org.ostelco.prime.apierror.ApiError +import org.ostelco.prime.customer.endpoint.model.Person +import org.ostelco.prime.model.ApplicationToken +import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Context +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.Product +import org.ostelco.prime.model.PurchaseRecord +import org.ostelco.prime.model.RegionDetails +import org.ostelco.prime.model.ScanInformation +import org.ostelco.prime.model.SimProfile +import org.ostelco.prime.model.Subscription +import org.ostelco.prime.paymentprocessor.core.ProductInfo +import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo +import org.ostelco.prime.paymentprocessor.core.SourceInfo + + +/** + * + */ +interface SubscriberDAO { + + // + // Customer + // + + fun getCustomer(identity: Identity): Either + + fun createCustomer(identity: Identity, customer: Customer, referredBy: String?): Either + + fun updateCustomer(identity: Identity, nickname: String?, contactEmail: String?): Either + + fun removeCustomer(identity: Identity): Either + + // + // Context + // + fun getContext(identity: Identity): Either + + // + // Regions + // + fun getRegions(identity: Identity): Either> + + fun getRegion(identity: Identity, regionCode: String): Either + + // + // Subscriptions + // + + fun getSubscriptions(identity: Identity, regionCode: String): Either> + + // + // SIM Profile + // + + fun getSimProfiles(identity: Identity, regionCode: String): Either> + + fun provisionSimProfile(identity: Identity, regionCode: String, profileType: String?): Either + + // + // Bundle + // + fun getBundles(identity: Identity): Either> + + // + // Products + // + + fun getPurchaseHistory(identity: Identity): Either> + + fun getProduct(identity: Identity, sku: String): Either + + fun getProducts(identity: Identity): Either> + + fun purchaseProduct(identity: Identity, sku: String, sourceId: String?, saveCard: Boolean): Either + + // + // Payment + // + + fun createSource(identity: Identity, sourceId: String): Either + + fun setDefaultSource(identity: Identity, sourceId: String): Either + + fun listSources(identity: Identity): Either> + + fun removeSource(identity: Identity, sourceId: String): Either + + fun getStripeEphemeralKey(identity: Identity, apiVersion: String): Either + + // + // Referrals + // + + fun getReferrals(identity: Identity): Either> + + fun getReferredBy(identity: Identity): Either + + // + // eKYC + // + + fun createNewJumioKycScanId(identity: Identity, regionCode: String): Either + + fun getCountryCodeForScan(scanId: String): Either + + fun getScanInformation(identity: Identity, scanId: String): Either + + fun getCustomerMyInfoData(identity: Identity, authorisationCode: String): Either + + fun checkNricFinIdUsingDave(identity: Identity, nricFinId: String): Either + + fun saveAddressAndPhoneNumber(identity: Identity, address: String, phoneNumber: String): Either + + // + // Token + // + + fun storeApplicationToken(customerId: String, applicationToken: ApplicationToken): Either + + companion object { + + /** + * The application token is only valid if token, + * applicationID and token type is set. + */ + fun isValidApplicationToken(appToken: ApplicationToken?): Boolean { + return (appToken != null + && !appToken.token.isEmpty() + && !appToken.applicationID.isEmpty() + && !appToken.tokenType.isEmpty()) + } + } +} diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAOImpl.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAOImpl.kt new file mode 100644 index 000000000..9ec81f1db --- /dev/null +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAOImpl.kt @@ -0,0 +1,418 @@ +package org.ostelco.prime.customer.endpoint.store + +import arrow.core.Either +import arrow.core.flatMap +import org.ostelco.prime.apierror.ApiError +import org.ostelco.prime.apierror.ApiErrorCode +import org.ostelco.prime.apierror.ApiErrorMapper.mapPaymentErrorToApiError +import org.ostelco.prime.apierror.ApiErrorMapper.mapStorageErrorToApiError +import org.ostelco.prime.apierror.BadRequestError +import org.ostelco.prime.apierror.InternalServerError +import org.ostelco.prime.apierror.NotFoundError +import org.ostelco.prime.customer.endpoint.metrics.updateMetricsOnNewSubscriber +import org.ostelco.prime.customer.endpoint.model.Person +import org.ostelco.prime.getLogger +import org.ostelco.prime.model.ApplicationToken +import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Context +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.Product +import org.ostelco.prime.model.PurchaseRecord +import org.ostelco.prime.model.RegionDetails +import org.ostelco.prime.model.ScanInformation +import org.ostelco.prime.model.SimProfile +import org.ostelco.prime.model.Subscription +import org.ostelco.prime.module.getResource +import org.ostelco.prime.paymentprocessor.PaymentProcessor +import org.ostelco.prime.paymentprocessor.core.ProductInfo +import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo +import org.ostelco.prime.paymentprocessor.core.SourceInfo +import org.ostelco.prime.storage.ClientDataSource + +/** + * + */ +class SubscriberDAOImpl : SubscriberDAO { + + private val logger by getLogger() + + private val storage by lazy { getResource() } + private val paymentProcessor by lazy { getResource() } + + // + // Customer + // + + override fun getCustomer(identity: Identity): Either { + return try { + storage.getCustomer(identity).mapLeft { + NotFoundError("Failed to fetch customer.", ApiErrorCode.FAILED_TO_FETCH_CUSTOMER, it) + } + } catch (e: Exception) { + logger.error("Failed to fetch customer with identity - $identity", e) + Either.left(NotFoundError("Failed to fetch customer", ApiErrorCode.FAILED_TO_FETCH_CUSTOMER)) + } + } + + override fun createCustomer( + identity: Identity, + customer: Customer, + referredBy: String?): Either { + + return try { + storage.addCustomer(identity, customer, referredBy) + .mapLeft { + mapStorageErrorToApiError("Failed to create customer.", ApiErrorCode.FAILED_TO_CREATE_CUSTOMER, it) + } + .flatMap { + updateMetricsOnNewSubscriber() + getCustomer(identity) + } + } catch (e: Exception) { + logger.error("Failed to create customer with identity - $identity", e) + Either.left(InternalServerError("Failed to create customer", ApiErrorCode.FAILED_TO_CREATE_CUSTOMER)) + } + } + + override fun updateCustomer(identity: Identity, nickname: String?, contactEmail: String?): Either { + try { + storage.updateCustomer(identity = identity, nickname = nickname, contactEmail = contactEmail) + } catch (e: Exception) { + logger.error("Failed to update customer with identity - $identity", e) + return Either.left(InternalServerError("Failed to update customer", ApiErrorCode.FAILED_TO_UPDATE_CUSTOMER)) + } + + return getCustomer(identity) + } + + override fun removeCustomer(identity: Identity): Either { + return try { + storage.removeCustomer(identity).mapLeft { + NotFoundError("Failed to remove customer.", ApiErrorCode.FAILED_TO_REMOVE_CUSTOMER, it) + } + } catch (e: Exception) { + logger.error("Failed to fetch customer with identity - $identity", e) + Either.left(NotFoundError("Failed to remove customer", ApiErrorCode.FAILED_TO_REMOVE_CUSTOMER)) + } + } + + // + // Context + // + + override fun getContext(identity: Identity): Either { + return try { + storage.getCustomer(identity) + .mapLeft { + NotFoundError("Failed to fetch customer.", ApiErrorCode.FAILED_TO_FETCH_CONTEXT, it) + } + .map { customer -> + storage.getAllRegionDetails(identity = identity) + .fold( + { Context(customer = customer) }, + { regionDetailsCollection -> Context(customer = customer, regions = regionDetailsCollection) }) + } + } catch (e: Exception) { + logger.error("Failed to fetch context for customer with identity - $identity", e) + Either.left(NotFoundError("Failed to fetch context", ApiErrorCode.FAILED_TO_FETCH_CONTEXT)) + } + } + + // + // Regions + // + override fun getRegions(identity: Identity): Either> { + return try { + storage.getAllRegionDetails(identity).mapLeft { + NotFoundError("Failed to get regions.", ApiErrorCode.FAILED_TO_FETCH_REGIONS, it) + } + } catch (e: Exception) { + logger.error("Failed to get regions for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get regions", ApiErrorCode.FAILED_TO_FETCH_REGIONS)) + } + } + + override fun getRegion(identity: Identity, regionCode: String): Either { + return try { + storage.getRegionDetails(identity, regionCode).mapLeft { + NotFoundError("Failed to get regions.", ApiErrorCode.FAILED_TO_FETCH_REGIONS, it) + } + } catch (e: Exception) { + logger.error("Failed to get regions for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get regions", ApiErrorCode.FAILED_TO_FETCH_REGIONS)) + } + } + + // + // Subscriptions + // + + override fun getSubscriptions(identity: Identity, regionCode: String): Either> { + return try { + storage.getSubscriptions(identity, regionCode).mapLeft { + NotFoundError("Failed to get subscriptions.", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS, it) + } + } catch (e: Exception) { + logger.error("Failed to get subscriptions for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get subscriptions", ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTIONS)) + } + } + + // + // SIM Profile + // + + override fun getSimProfiles(identity: Identity, regionCode: String): Either> { + return try { + storage.getSimProfiles(identity, regionCode).mapLeft { + NotFoundError("Failed to fetch SIM profiles.", ApiErrorCode.FAILED_TO_FETCH_SIM_PROFILES, it) + } + } catch (e: Exception) { + logger.error("Failed to fetch SIM profiles for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to fetch SIM profiles", ApiErrorCode.FAILED_TO_FETCH_SIM_PROFILES)) + } + } + + override fun provisionSimProfile(identity: Identity, regionCode: String, profileType: String?): Either { + return try { + storage.provisionSimProfile(identity, regionCode, profileType).mapLeft { + NotFoundError("Failed to provision SIM profile.", ApiErrorCode.FAILED_TO_PROVISION_SIM_PROFILE, it) + } + } catch (e: Exception) { + logger.error("Failed to provision SIM profile for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to provision SIM profile", ApiErrorCode.FAILED_TO_PROVISION_SIM_PROFILE)) + } + } + + // + // Bundle + // + + override fun getBundles(identity: Identity): Either> { + return try { + storage.getBundles(identity).mapLeft { + NotFoundError("Failed to get bundles. ${it.message}", ApiErrorCode.FAILED_TO_FETCH_BUNDLES) + } + } catch (e: Exception) { + logger.error("Failed to get bundles for customer with identity - $identity", e) + Either.left(NotFoundError("Failed to get bundles", ApiErrorCode.FAILED_TO_FETCH_BUNDLES)) + } + } + + // + // Products + // + + override fun getPurchaseHistory(identity: Identity): Either> { + return try { + return storage.getPurchaseRecords(identity).bimap( + { NotFoundError("Failed to get purchase history.", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_HISTORY, it) }, + { it.toList() }) + } catch (e: Exception) { + logger.error("Failed to get purchase history for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get purchase history", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_HISTORY)) + } + } + + override fun getProducts(identity: Identity): Either> { + return try { + storage.getProducts(identity).bimap( + { NotFoundError("Failed to fetch products", ApiErrorCode.FAILED_TO_FETCH_PRODUCT_LIST, it) }, + { products -> products.values }) + } catch (e: Exception) { + logger.error("Failed to get Products for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get Products", ApiErrorCode.FAILED_TO_FETCH_PRODUCT_LIST)) + } + + } + + override fun getProduct(identity: Identity, sku: String): Either { + return storage.getProduct(identity, sku) + .fold({ Either.left(NotFoundError("Failed to get products for sku $sku", ApiErrorCode.FAILED_TO_FETCH_PRODUCT_INFORMATION)) }, + { Either.right(it) }) + } + + override fun purchaseProduct( + identity: Identity, + sku: String, + sourceId: String?, + saveCard: Boolean): Either = + storage.purchaseProduct( + identity, + sku, + sourceId, + saveCard).mapLeft { mapPaymentErrorToApiError("Failed to purchase product. ", ApiErrorCode.FAILED_TO_PURCHASE_PRODUCT, it) } + + // + // Payment + // + + override fun createSource(identity: Identity, sourceId: String): Either { + return storage.getCustomer(identity) + .mapLeft { error -> mapStorageErrorToApiError(error.message, ApiErrorCode.FAILED_TO_FETCH_CUSTOMER, error) } + .flatMap { customer -> + paymentProcessor.getPaymentProfile(customerId = customer.id) + .fold( + { + paymentProcessor.createPaymentProfile(customerId = customer.id, email = customer.contactEmail) + .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_STORE_PAYMENT_SOURCE, error) } + }, + { profileInfo -> Either.right(profileInfo) } + ) + }.flatMap { profileInfo -> + paymentProcessor.addSource(profileInfo.id, sourceId) + .mapLeft { mapPaymentErrorToApiError("Failed to store payment source", ApiErrorCode.FAILED_TO_STORE_PAYMENT_SOURCE, it) } + } + } + + override fun setDefaultSource(identity: Identity, sourceId: String): Either { + return storage.getCustomer(identity) + .mapLeft { error -> mapStorageErrorToApiError(error.message, ApiErrorCode.FAILED_TO_FETCH_CUSTOMER_ID, error) } + .flatMap { customer -> + paymentProcessor.getPaymentProfile(customerId = customer.id) + .fold( + { + paymentProcessor.createPaymentProfile(customerId = customer.id, email = customer.contactEmail) + .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_SET_DEFAULT_PAYMENT_SOURCE, error) } + }, + { profileInfo -> Either.right(profileInfo) } + ) + } + .flatMap { profileInfo -> + paymentProcessor.setDefaultSource(profileInfo.id, sourceId) + .mapLeft { mapPaymentErrorToApiError("Failed to set default payment source", ApiErrorCode.FAILED_TO_SET_DEFAULT_PAYMENT_SOURCE, it) } + } + } + + override fun listSources(identity: Identity): Either> { + return storage.getCustomer(identity) + .mapLeft { error -> mapStorageErrorToApiError(error.message, ApiErrorCode.FAILED_TO_FETCH_CUSTOMER_ID, error) } + .flatMap { customer -> + paymentProcessor.getPaymentProfile(customerId = customer.id) + .fold( + { + paymentProcessor.createPaymentProfile(customerId = customer.id, email = customer.contactEmail) + .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_FETCH_PAYMENT_SOURCES_LIST, error) } + }, + { profileInfo -> Either.right(profileInfo) } + ) + } + .flatMap { profileInfo -> + paymentProcessor.getSavedSources(profileInfo.id) + .mapLeft { mapPaymentErrorToApiError("Failed to list sources", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_SOURCES_LIST, it) } + } + } + + override fun removeSource(identity: Identity, sourceId: String): Either { + return storage.getCustomerId(identity) + .mapLeft { error -> mapStorageErrorToApiError(error.message, ApiErrorCode.FAILED_TO_FETCH_CUSTOMER_ID, error) } + .flatMap { customerId -> + paymentProcessor.getPaymentProfile(customerId = customerId) + .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_REMOVE_PAYMENT_SOURCE, error) } + } + .flatMap { profileInfo -> + paymentProcessor.removeSource(profileInfo.id, sourceId) + .mapLeft { mapPaymentErrorToApiError("Failed to remove payment source", ApiErrorCode.FAILED_TO_REMOVE_PAYMENT_SOURCE, it) } + } + } + + override fun getStripeEphemeralKey(identity: Identity, apiVersion: String): Either { + return storage.getCustomer(identity) + .mapLeft { error -> mapStorageErrorToApiError(error.message, ApiErrorCode.FAILED_TO_FETCH_CUSTOMER_ID, error) } + .flatMap { customer -> + paymentProcessor.getStripeEphemeralKey(customerId = customer.id, email = customer.contactEmail, apiVersion = apiVersion) + .mapLeft { error -> mapPaymentErrorToApiError(error.description, ApiErrorCode.FAILED_TO_GENERATE_STRIPE_EPHEMERAL_KEY, error) } + } + } + + // + // Referrals + // + + override fun getReferrals(identity: Identity): Either> { + return try { + storage.getReferrals(identity).bimap( + { NotFoundError("Failed to get referral list.", ApiErrorCode.FAILED_TO_FETCH_REFERRALS, it) }, + { list -> list.map { Person(it) } }) + } catch (e: Exception) { + logger.error("Failed to get referral list for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get referral list", ApiErrorCode.FAILED_TO_FETCH_REFERRALS)) + } + } + + override fun getReferredBy(identity: Identity): Either { + return try { + storage.getReferredBy(identity).bimap( + { NotFoundError("Failed to get referred-by.", ApiErrorCode.FAILED_TO_FETCH_REFERRED_BY_LIST, it) }, + { Person(name = it) }) + } catch (e: Exception) { + logger.error("Failed to get referred-by for customer with identity - $identity", e) + Either.left(InternalServerError("Failed to get referred-by", ApiErrorCode.FAILED_TO_FETCH_REFERRED_BY_LIST)) + } + } + + // + // eKYC + // + + override fun createNewJumioKycScanId(identity: Identity, regionCode: String): Either { + return storage.createNewJumioKycScanId(identity, regionCode) + .mapLeft { mapStorageErrorToApiError("Failed to create new scanId", ApiErrorCode.FAILED_TO_CREATE_SCANID, it) } + } + + override fun getCountryCodeForScan(scanId: String): Either { + return storage.getCountryCodeForScan(scanId) + .mapLeft { mapStorageErrorToApiError("Failed to get country code of the scanId", ApiErrorCode.FAILED_TO_FETCH_SCAN_INFORMATION, it) } + } + + override fun getScanInformation(identity: Identity, scanId: String): Either { + return storage.getScanInformation(identity, scanId) + .mapLeft { mapStorageErrorToApiError("Failed to fetch scan information", ApiErrorCode.FAILED_TO_FETCH_SCAN_INFORMATION, it) } + } + + override fun getCustomerMyInfoData(identity: Identity, authorisationCode: String): Either { + return storage.getCustomerMyInfoData(identity, authorisationCode) + .mapLeft { mapStorageErrorToApiError("Failed to fetch Customer Data from MyInfo", ApiErrorCode.FAILED_TO_FETCH_CUSTOMER_MYINFO_DATA, it) } + } + + override fun checkNricFinIdUsingDave(identity: Identity, nricFinId: String): Either { + return storage.checkNricFinIdUsingDave(identity, nricFinId) + .mapLeft { mapStorageErrorToApiError("Invalid NRIC/FIN ID", ApiErrorCode.INVALID_NRIC_FIN_ID, it) } + } + + override fun saveAddressAndPhoneNumber(identity: Identity, address: String, phoneNumber: String): Either { + return storage.saveAddressAndPhoneNumber(identity = identity, address = address, phoneNumber = phoneNumber) + .mapLeft { mapStorageErrorToApiError("Failed to save address and phone number", ApiErrorCode.FAILED_TO_SAVE_ADDRESS_AND_PHONE_NUMBER, it) } + } + + // + // Token + // + + override fun storeApplicationToken(customerId: String, applicationToken: ApplicationToken): Either { + + if (!SubscriberDAO.isValidApplicationToken(applicationToken)) { + return Either.left(BadRequestError("Incomplete ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) + } + + try { + storage.addNotificationToken(customerId, applicationToken) + } catch (e: Exception) { + logger.error("Failed to store ApplicationToken for customerId $customerId", e) + return Either.left(InternalServerError("Failed to store ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) + } + return getNotificationToken(customerId, applicationToken.applicationID) + } + + private fun getNotificationToken(customerId: String, applicationId: String): Either { + try { + return storage.getNotificationToken(customerId, applicationId) + ?.let { Either.right(it) } + ?: return Either.left(NotFoundError("Failed to get ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) + } catch (e: Exception) { + logger.error("Failed to get ApplicationToken for customerId $customerId", e) + return Either.left(InternalServerError("Failed to get ApplicationToken", ApiErrorCode.FAILED_TO_STORE_APPLICATION_TOKEN)) + } + } +} diff --git a/client-api/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable b/customer-endpoint/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable similarity index 100% rename from client-api/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable rename to customer-endpoint/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable diff --git a/customer-endpoint/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/customer-endpoint/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule new file mode 100644 index 000000000..9cc115171 --- /dev/null +++ b/customer-endpoint/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule @@ -0,0 +1 @@ +org.ostelco.prime.customer.endpoint.CustomerEndpointModule \ No newline at end of file diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ApplicationTokenResourceTest.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/ApplicationTokenResourceTest.kt similarity index 78% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ApplicationTokenResourceTest.kt rename to customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/ApplicationTokenResourceTest.kt index e129dadb8..2bf3a9fc7 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ApplicationTokenResourceTest.kt +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/ApplicationTokenResourceTest.kt @@ -1,14 +1,13 @@ -package org.ostelco.prime.client.api.resources +package org.ostelco.prime.customer.endpoint.resources import arrow.core.Either -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import arrow.core.right import com.nhaarman.mockito_kotlin.argumentCaptor import io.dropwizard.auth.AuthDynamicFeature import io.dropwizard.auth.AuthValueFactoryProvider import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter import io.dropwizard.testing.junit.ResourceTestRule import org.assertj.core.api.Assertions.assertThat -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory import org.junit.Before import org.junit.ClassRule import org.junit.Test @@ -16,11 +15,14 @@ import org.mockito.ArgumentMatchers import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.ostelco.prime.apierror.ApiError -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.customer.endpoint.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper import org.ostelco.prime.model.ApplicationToken +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity import java.util.* import javax.ws.rs.client.Client import javax.ws.rs.client.Entity @@ -47,7 +49,7 @@ class ApplicationTokenResourceTest { @Before fun setUp() { `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) + .thenReturn(Optional.of(AccessTokenPrincipal(email, "email"))) } @Test @@ -55,14 +57,14 @@ class ApplicationTokenResourceTest { val arg1 = argumentCaptor() val arg2 = argumentCaptor() - val argMsisdn = argumentCaptor() - val msisdn = "4790300001" + val argIdentity = argumentCaptor() + val customer = Customer(contactEmail = email, nickname = "foo") `when`(DAO.storeApplicationToken(arg1.capture(), arg2.capture())) .thenReturn(Either.right(applicationToken)) - `when`>(DAO.getMsisdn(argMsisdn.capture())).thenReturn(Either.right(msisdn)) + `when`>(DAO.getCustomer(argIdentity.capture())).thenReturn(customer.right()) - val resp = RULE.target("/applicationtoken") + val resp = RULE.target("/applicationToken") .request(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") @@ -74,7 +76,7 @@ class ApplicationTokenResourceTest { assertThat(resp.status).isEqualTo(Response.Status.CREATED.statusCode) assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) - assertThat(arg1.firstValue).isEqualTo(msisdn) + assertThat(arg1.firstValue).isEqualTo(customer.id) assertThat(arg2.firstValue.token).isEqualTo(token) assertThat(arg2.firstValue.applicationID).isEqualTo(applicationID) assertThat(arg2.firstValue.tokenType).isEqualTo(tokenType) @@ -89,7 +91,7 @@ class ApplicationTokenResourceTest { @JvmField @ClassRule val RULE = ResourceTestRule.builder() - .setMapper(jacksonObjectMapper()) + .setMapper(objectMapper) .addResource(AuthDynamicFeature( OAuthCredentialAuthFilter.Builder() .setAuthenticator(AUTHENTICATOR) @@ -97,7 +99,6 @@ class ApplicationTokenResourceTest { .buildAuthFilter())) .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) .addResource(ApplicationTokenResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) .build() } } diff --git a/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/CustomerResourceTest.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/CustomerResourceTest.kt new file mode 100644 index 000000000..a6ab79435 --- /dev/null +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/CustomerResourceTest.kt @@ -0,0 +1,156 @@ +package org.ostelco.prime.customer.endpoint.resources + +import arrow.core.Either +import com.nhaarman.mockito_kotlin.argumentCaptor +import io.dropwizard.auth.AuthDynamicFeature +import io.dropwizard.auth.AuthValueFactoryProvider +import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter +import io.dropwizard.testing.junit.ResourceTestRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.customer.endpoint.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity +import java.util.* +import javax.ws.rs.client.Entity +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * Profile API tests. + * + */ +class CustomerResourceTest { + + private val email = "boaty@internet.org" + private val name = "Boaty McBoatface" + + private val profile = Customer(nickname = name, contactEmail = email) + + @Before + fun setUp() { + `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) + .thenReturn(Optional.of(AccessTokenPrincipal(email, "email"))) + } + + @Test + fun getProfile() { + val arg = argumentCaptor() + + `when`(DAO.getCustomer(arg.capture())).thenReturn(Either.right(profile)) + + val resp = RULE.target("/customer") + .request() + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .get(Response::class.java) + + assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) + assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + + assertThat(resp.readEntity(Customer::class.java)).isEqualTo(profile) + assertThat(arg.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + } + + @Test + fun createProfile() { + val arg1 = argumentCaptor() + val arg2 = argumentCaptor() + val arg3 = argumentCaptor() + + + `when`(DAO.createCustomer(arg1.capture(), arg2.capture(), arg3.capture())) + .thenReturn(Either.right(profile)) + + val resp = RULE.target("/customer") + .queryParam("nickname", name) + .queryParam("contactEmail", email) + .request(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .post(Entity.json("{}")) + + assertThat(resp.status).isEqualTo(Response.Status.CREATED.statusCode) + assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + assertThat(arg1.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + assertThat(arg2.firstValue.contactEmail).isEqualTo(email) + assertThat(arg2.firstValue.nickname).isEqualTo(name) + assertThat(arg3.firstValue).isNull() + } + + @Test + fun createProfileWithReferral() { + val arg1 = argumentCaptor() + val arg2 = argumentCaptor() + val arg3 = argumentCaptor() + + val referredBy = "foo@bar.com" + + `when`(DAO.createCustomer(arg1.capture(), arg2.capture(), arg3.capture())) + .thenReturn(Either.right(profile)) + + val resp = RULE.target("/customer") + .queryParam("nickname", name) + .queryParam("contactEmail", email) + .queryParam("referredBy", referredBy) + .request(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .post(Entity.json("")) + + assertThat(resp.status).isEqualTo(Response.Status.CREATED.statusCode) + assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + assertThat(arg1.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + assertThat(arg2.firstValue.contactEmail).isEqualTo(email) + assertThat(arg2.firstValue.nickname).isEqualTo(name) + assertThat(arg3.firstValue).isEqualTo(referredBy) + } + + @Test + fun updateProfile() { + val identityCaptor = argumentCaptor() + val nicknameCaptor = argumentCaptor() + val contactEmailCaptor = argumentCaptor() + + `when`(DAO.updateCustomer(identityCaptor.capture(), nicknameCaptor.capture(), contactEmailCaptor.capture())) + .thenReturn(Either.right(profile)) + + val resp = RULE.target("/customer") + .queryParam("nickname", name) + .queryParam("contactEmail", email) + .request(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .put(Entity.json("{}")) + + assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) + assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + assertThat(identityCaptor.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + assertThat(nicknameCaptor.firstValue).isEqualTo(name) + assertThat(contactEmailCaptor.firstValue).isEqualTo(email) + } + + companion object { + + val DAO = mock(SubscriberDAO::class.java) + val AUTHENTICATOR = mock(OAuthAuthenticator::class.java) + + @JvmField + @ClassRule + val RULE = ResourceTestRule.builder() + .setMapper(objectMapper) + .addResource(AuthDynamicFeature( + OAuthCredentialAuthFilter.Builder() + .setAuthenticator(AUTHENTICATOR) + .setPrefix("Bearer") + .buildAuthFilter())) + .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) + .addResource(CustomerResource(DAO)) + .build() + } +} diff --git a/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/KycResourcesTest.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/KycResourcesTest.kt new file mode 100644 index 000000000..06ad1b4a4 --- /dev/null +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/KycResourcesTest.kt @@ -0,0 +1,160 @@ +package org.ostelco.prime.customer.endpoint.resources + +import arrow.core.Either +import arrow.core.right +import com.nhaarman.mockito_kotlin.argumentCaptor +import io.dropwizard.auth.AuthDynamicFeature +import io.dropwizard.auth.AuthValueFactoryProvider +import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter.Builder +import io.dropwizard.testing.junit.ResourceTestRule +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.ostelco.prime.apierror.ApiError +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.customer.endpoint.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.ScanInformation +import org.ostelco.prime.model.ScanStatus +import java.util.* +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +class KycResourcesTest { + + private val email = "mw@internet.org" + + @Test + fun `test Jumio for Norway`() { + + val scanInfo = ScanInformation( + scanId = "scan123", + countryCode = "no", + status = ScanStatus.PENDING, + scanResult = null) + + val identityCaptor = argumentCaptor() + val scanIdCaptor = argumentCaptor() + + `when`>(DAO.getScanInformation(identityCaptor.capture(), scanIdCaptor.capture())) + .thenReturn(scanInfo.right()) + + val resp = RULE.target("regions/no/kyc/jumio/scans/scan123") + .request() + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .get(Response::class.java) + + Assertions.assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) + Assertions.assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + + Assertions.assertThat(identityCaptor.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + Assertions.assertThat(scanIdCaptor.firstValue).isEqualTo("scan123") + + Assertions.assertThat(resp.readEntity(ScanInformation::class.java)) + .isEqualTo(scanInfo) + } + + @Test + fun `test Jumio for Singapore`() { + + val scanInfo = ScanInformation( + scanId = "scan123", + countryCode = "sg", + status = ScanStatus.PENDING, + scanResult = null) + + val identityCaptor = argumentCaptor() + val scanIdCaptor = argumentCaptor() + + `when`>(DAO.getScanInformation(identityCaptor.capture(), scanIdCaptor.capture())) + .thenReturn(scanInfo.right()) + + val resp = RULE.target("regions/sg/kyc/jumio/scans/scan123") + .request() + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .get(Response::class.java) + + Assertions.assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) + Assertions.assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + + Assertions.assertThat(identityCaptor.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + Assertions.assertThat(scanIdCaptor.firstValue).isEqualTo("scan123") + + Assertions.assertThat(resp.readEntity(ScanInformation::class.java)) + .isEqualTo(scanInfo) + } + + @Test + fun `test myInfo for Singapore`() { + + val identityCaptor = argumentCaptor() + val authorisationCodeCaptor = argumentCaptor() + + `when`>(DAO.getCustomerMyInfoData(identityCaptor.capture(), authorisationCodeCaptor.capture())) + .thenReturn("{}".right()) + + val resp = RULE.target("regions/sg/kyc/myInfo/code123") + .request() + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .get(Response::class.java) + + Assertions.assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) + Assertions.assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + + Assertions.assertThat(identityCaptor.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + Assertions.assertThat(authorisationCodeCaptor.firstValue).isEqualTo("code123") + + Assertions.assertThat(resp.readEntity(String::class.java)) + .isEqualTo("{}") + } + + @Test + fun `test myInfo for Norway`() { + + val identityCaptor = argumentCaptor() + val authorisationCodeCaptor = argumentCaptor() + + `when`>(DAO.getCustomerMyInfoData(identityCaptor.capture(), authorisationCodeCaptor.capture())) + .thenReturn("{}".right()) + + val resp = RULE.target("regions/no/kyc/myInfo/code123") + .request() + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .get(Response::class.java) + + Assertions.assertThat(resp.status).isEqualTo(Response.Status.NOT_FOUND.statusCode) + Assertions.assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + } + + @Before + fun setUp() { + Mockito.`when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) + .thenReturn(Optional.of(AccessTokenPrincipal(email, "email"))) + } + + companion object { + + val DAO: SubscriberDAO = Mockito.mock(SubscriberDAO::class.java) + val AUTHENTICATOR: OAuthAuthenticator = Mockito.mock(OAuthAuthenticator::class.java) + + @JvmField + @ClassRule + val RULE: ResourceTestRule = ResourceTestRule.builder() + .setMapper(objectMapper) + .addResource(AuthDynamicFeature( + Builder() + .setAuthenticator(AUTHENTICATOR) + .setPrefix("Bearer") + .buildAuthFilter())) + .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) + .addResource(RegionsResource(DAO)) + .build() + } +} \ No newline at end of file diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ProductsResourceTest.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResourceTest.kt similarity index 84% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ProductsResourceTest.kt rename to customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResourceTest.kt index 3d5f578a5..aaa7de1bc 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/ProductsResourceTest.kt +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResourceTest.kt @@ -1,14 +1,12 @@ -package org.ostelco.prime.client.api.resources +package org.ostelco.prime.customer.endpoint.resources import arrow.core.Either -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.nhaarman.mockito_kotlin.argumentCaptor import io.dropwizard.auth.AuthDynamicFeature import io.dropwizard.auth.AuthValueFactoryProvider import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter import io.dropwizard.testing.junit.ResourceTestRule import org.assertj.core.api.Assertions.assertThat -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory import org.junit.Assert.assertTrue import org.junit.Before import org.junit.ClassRule @@ -17,10 +15,12 @@ import org.mockito.ArgumentMatchers import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.ostelco.prime.apierror.ApiError -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.customer.endpoint.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.Price import org.ostelco.prime.model.Product import org.ostelco.prime.paymentprocessor.PaymentProcessor @@ -58,12 +58,12 @@ class ProductsResourceTest { @Before fun setUp() { `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) + .thenReturn(Optional.of(AccessTokenPrincipal(email, "email"))) } @Test fun getProducts() { - val arg = argumentCaptor() + val arg = argumentCaptor() `when`>>(DAO.getProducts(arg.capture())).thenReturn(Either.right(products)) @@ -80,12 +80,12 @@ class ProductsResourceTest { assertTrue(products == resp.readEntity(object : GenericType>() { })) - assertThat(arg.firstValue).isEqualTo(email) + assertThat(arg.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) } @Test fun purchaseProduct() { - val emailArg = argumentCaptor() + val identityArg = argumentCaptor() val skuArg = argumentCaptor() val sourceIdArg = argumentCaptor() val saveSourceArg = argumentCaptor() @@ -94,7 +94,7 @@ class ProductsResourceTest { val sourceId = "amex" `when`(DAO.purchaseProduct( - emailArg.capture(), + identityArg.capture(), skuArg.capture(), sourceIdArg.capture(), saveSourceArg.capture())).thenReturn(Either.right(ProductInfo(sku))) @@ -107,7 +107,7 @@ class ProductsResourceTest { .post(Entity.text("")) assertThat(resp.status).isEqualTo(Response.Status.CREATED.statusCode) - assertThat(emailArg.allValues.toSet()).isEqualTo(setOf(email)) + assertThat(identityArg.allValues.toSet()).isEqualTo(setOf(Identity(email, "EMAIL", "email"))) assertThat(skuArg.allValues.toSet()).isEqualTo(setOf(sku)) assertThat(sourceIdArg.allValues.toSet()).isEqualTo(setOf(sourceId)) assertThat(saveSourceArg.allValues.toSet()).isEqualTo(setOf(false)) @@ -121,7 +121,7 @@ class ProductsResourceTest { @JvmField @ClassRule val RULE = ResourceTestRule.builder() - .setMapper(jacksonObjectMapper()) + .setMapper(objectMapper) .addResource(AuthDynamicFeature( OAuthCredentialAuthFilter.Builder() .setAuthenticator(AUTHENTICATOR) @@ -129,7 +129,6 @@ class ProductsResourceTest { .buildAuthFilter())) .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) .addResource(ProductsResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) .build() } } diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/PurchasesResourceTest.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/PurchasesResourceTest.kt similarity index 80% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/PurchasesResourceTest.kt rename to customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/PurchasesResourceTest.kt index 5301f2101..bf63500c8 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/PurchasesResourceTest.kt +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/PurchasesResourceTest.kt @@ -1,24 +1,24 @@ -package org.ostelco.prime.client.api.resources +package org.ostelco.prime.customer.endpoint.resources import arrow.core.Either -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.nhaarman.mockito_kotlin.argumentCaptor import io.dropwizard.auth.AuthDynamicFeature import io.dropwizard.auth.AuthValueFactoryProvider import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter.Builder import io.dropwizard.testing.junit.ResourceTestRule import org.assertj.core.api.Assertions -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory import org.junit.Before import org.junit.ClassRule import org.junit.Test import org.mockito.ArgumentMatchers import org.mockito.Mockito import org.ostelco.prime.apierror.ApiError -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.customer.endpoint.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.Price import org.ostelco.prime.model.Product import org.ostelco.prime.model.PurchaseRecord @@ -43,20 +43,19 @@ class PurchasesResourceTest { @Before fun setUp() { Mockito.`when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) + .thenReturn(Optional.of(AccessTokenPrincipal(email, "email"))) } @Test fun testGetPurchaseRecords() { - val arg1 = argumentCaptor() + val arg1 = argumentCaptor() val product = Product("1", Price(10, "NOK"), Collections.emptyMap(), Collections.emptyMap()) val now = Instant.now().toEpochMilli() val purchaseRecord = PurchaseRecord( product = product, timestamp = now, - id = UUID.randomUUID().toString(), - msisdn = "") + id = UUID.randomUUID().toString()) Mockito.`when`>>(DAO.getPurchaseHistory(arg1.capture())) .thenReturn(Either.right(listOf(purchaseRecord))) @@ -78,7 +77,7 @@ class PurchasesResourceTest { @JvmField @ClassRule val RULE = ResourceTestRule.builder() - .setMapper(jacksonObjectMapper()) + .setMapper(objectMapper) .addResource(AuthDynamicFeature( Builder() .setAuthenticator(AUTHENTICATOR) @@ -87,7 +86,6 @@ class PurchasesResourceTest { .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) .addResource(ProductsResource(DAO)) .addResource(PurchaseResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) .build() } } \ No newline at end of file diff --git a/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/RegionsResourceTest.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/RegionsResourceTest.kt new file mode 100644 index 000000000..3dfec7810 --- /dev/null +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/RegionsResourceTest.kt @@ -0,0 +1,87 @@ +package org.ostelco.prime.customer.endpoint.resources + +import arrow.core.Either +import arrow.core.right +import com.nhaarman.mockito_kotlin.argumentCaptor +import io.dropwizard.auth.AuthDynamicFeature +import io.dropwizard.auth.AuthValueFactoryProvider +import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter.Builder +import io.dropwizard.testing.junit.ResourceTestRule +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.ostelco.prime.apierror.ApiError +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.customer.endpoint.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.CustomerRegionStatus.PENDING +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.Region +import org.ostelco.prime.model.RegionDetails +import java.util.* +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +class RegionsResourceTest { + + private val email = "mw@internet.org" + + @Test + fun `test getRegions`() { + + val regions = listOf( + RegionDetails( + region = Region(id = "no", name = "Norway"), + status = PENDING)) + + val identityCaptor = argumentCaptor() + + `when`>>(DAO.getRegions(identityCaptor.capture())) + .thenReturn(regions.right()) + + val resp = RULE.target("regions") + .request() + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .get(Response::class.java) + + Assertions.assertThat(resp.status).isEqualTo(Response.Status.OK.statusCode) + Assertions.assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) + + Assertions.assertThat(identityCaptor.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + + Assertions.assertThat(resp.readEntity(Array::class.java)) + .isEqualTo(regions.toTypedArray()) + + } + + @Before + fun setUp() { + Mockito.`when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) + .thenReturn(Optional.of(AccessTokenPrincipal(email, "email"))) + } + + companion object { + + val DAO: SubscriberDAO = Mockito.mock(SubscriberDAO::class.java) + val AUTHENTICATOR: OAuthAuthenticator = Mockito.mock(OAuthAuthenticator::class.java) + + @JvmField + @ClassRule + val RULE: ResourceTestRule = ResourceTestRule.builder() + .setMapper(objectMapper) + .addResource(AuthDynamicFeature( + Builder() + .setAuthenticator(AUTHENTICATOR) + .setPrefix("Bearer") + .buildAuthFilter())) + .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) + .addResource(RegionsResource(DAO)) + .build() + } +} \ No newline at end of file diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/SubscriptionsResourceTest.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/SubscriptionsResourceTest.kt similarity index 67% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/SubscriptionsResourceTest.kt rename to customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/SubscriptionsResourceTest.kt index 61e45d517..ec6cfcf8a 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/resources/SubscriptionsResourceTest.kt +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/resources/SubscriptionsResourceTest.kt @@ -1,14 +1,12 @@ -package org.ostelco.prime.client.api.resources +package org.ostelco.prime.customer.endpoint.resources import arrow.core.Either -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.nhaarman.mockito_kotlin.argumentCaptor import io.dropwizard.auth.AuthDynamicFeature import io.dropwizard.auth.AuthValueFactoryProvider import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter import io.dropwizard.testing.junit.ResourceTestRule import org.assertj.core.api.Assertions.assertThat -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory import org.junit.Before import org.junit.ClassRule import org.junit.Test @@ -16,10 +14,12 @@ import org.mockito.ArgumentMatchers import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.ostelco.prime.apierror.ApiError -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.client.api.util.AccessToken +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.customer.endpoint.store.SubscriberDAO +import org.ostelco.prime.customer.endpoint.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.Subscription import java.util.* import javax.ws.rs.client.Invocation @@ -39,16 +39,19 @@ class SubscriptionsResourceTest { @Before fun setUp() { `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) - .thenReturn(Optional.of(AccessTokenPrincipal(email))) + .thenReturn(Optional.of(AccessTokenPrincipal(email, "email"))) } @Test fun getSubscriptions() { - val arg = argumentCaptor() + val identityCaptor = argumentCaptor() + val regionCodeCaptor = argumentCaptor() - `when`>>(DAO.getSubscriptions(arg.capture())).thenReturn(Either.right(listOf(subscription))) + `when`>>( + DAO.getSubscriptions(identityCaptor.capture(), regionCodeCaptor.capture())) + .thenReturn(Either.right(listOf(subscription))) - val resp = RULE.target("/subscriptions") + val resp = RULE.target("/regions/no/subscriptions") .request() .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") .get(Response::class.java) @@ -57,8 +60,9 @@ class SubscriptionsResourceTest { assertThat(resp.mediaType.toString()).isEqualTo(MediaType.APPLICATION_JSON) // assertThat and assertEquals is not working - assertThat(subscription).isEqualTo(resp.readEntity(Array::class.java)[0]) - assertThat(arg.firstValue).isEqualTo(email) + assertThat(resp.readEntity(Array::class.java)[0]).isEqualTo(subscription) + assertThat(identityCaptor.firstValue).isEqualTo(Identity(email, "EMAIL", "email")) + assertThat(regionCodeCaptor.firstValue).isEqualTo("no") } companion object { @@ -71,15 +75,14 @@ class SubscriptionsResourceTest { @JvmField @ClassRule val RULE: ResourceTestRule = ResourceTestRule.builder() - .setMapper(jacksonObjectMapper()) + .setMapper(objectMapper) .addResource(AuthDynamicFeature( OAuthCredentialAuthFilter.Builder() .setAuthenticator(AUTHENTICATOR) .setPrefix("Bearer") .buildAuthFilter())) .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) - .addResource(SubscriptionsResource(DAO)) - .setTestContainerFactory(GrizzlyWebTestContainerFactory()) + .addResource(RegionsResource(DAO)) .build() } } diff --git a/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/util/AccessToken.kt b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/util/AccessToken.kt new file mode 100644 index 000000000..ef8c709c6 --- /dev/null +++ b/customer-endpoint/src/test/kotlin/org/ostelco/prime/customer/endpoint/util/AccessToken.kt @@ -0,0 +1,29 @@ +package org.ostelco.prime.customer.endpoint.util + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm + +object AccessToken { + + private val key = "secret" + private val namespace = "https://ostelco.org" + + fun withEmail(email: String): String { + + val audience = listOf("http://kmmtest", "$namespace/userinfo") + + return withEmail(email, audience) + } + + private fun withEmail(email: String, audience: List): String { + + val claims = mapOf("$namespace/email" to email, + "aud" to audience, + "sub" to email) + + return Jwts.builder() + .setClaims(claims) + .signWith(SignatureAlgorithm.HS512, key) + .compact() + } +} diff --git a/client-api/src/test/resources/META-INF/services/org.ostelco.prime.paymentprocessor.PaymentProcessor b/customer-endpoint/src/test/resources/META-INF/services/org.ostelco.prime.paymentprocessor.PaymentProcessor similarity index 100% rename from client-api/src/test/resources/META-INF/services/org.ostelco.prime.paymentprocessor.PaymentProcessor rename to customer-endpoint/src/test/resources/META-INF/services/org.ostelco.prime.paymentprocessor.PaymentProcessor diff --git a/client-api/src/test/resources/fixtures/subscriptions.json b/customer-endpoint/src/test/resources/fixtures/subscriptions.json similarity index 100% rename from client-api/src/test/resources/fixtures/subscriptions.json rename to customer-endpoint/src/test/resources/fixtures/subscriptions.json diff --git a/client-api/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/customer-endpoint/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker similarity index 100% rename from client-api/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker rename to customer-endpoint/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/data-store/build.gradle b/data-store/build.gradle new file mode 100644 index 000000000..12b20f693 --- /dev/null +++ b/data-store/build.gradle @@ -0,0 +1,34 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "java-library" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +repositories { + maven { + url 'https://dl.bintray.com/palantir/releases' // docker-compose-rule is published on bintray + } +} +dependencies { + + implementation project(":prime-modules") + implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" + + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + implementation "com.google.cloud:google-cloud-datastore:$googleCloudVersion" + implementation "com.google.cloud:google-cloud-storage:$googleCloudVersion" + + testImplementation "com.palantir.docker.compose:docker-compose-rule-junit4:$dockerComposeJunitRuleVersion" + + testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" + + testImplementation "org.mockito:mockito-core:$mockitoVersion" +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/data-store/src/main/kotlin/org/ostelco/prime/store/datastore/Schema.kt b/data-store/src/main/kotlin/org/ostelco/prime/store/datastore/Schema.kt new file mode 100644 index 000000000..4d135be9b --- /dev/null +++ b/data-store/src/main/kotlin/org/ostelco/prime/store/datastore/Schema.kt @@ -0,0 +1,135 @@ +package org.ostelco.prime.store.datastore + +import arrow.core.Either +import arrow.core.Try +import arrow.core.left +import arrow.core.right +import com.fasterxml.jackson.core.type.TypeReference +import com.google.cloud.NoCredentials +import com.google.cloud.Timestamp +import com.google.cloud.datastore.* +import com.google.cloud.datastore.testing.LocalDatastoreHelper +import com.google.cloud.http.HttpTransportOptions +import org.ostelco.prime.jsonmapper.objectMapper + + +class EntityStore( + private val entityClass: Class, + type: String = "inmemory-emulator", + namespace: String = "") { + + private val keyFactory: KeyFactory + private val datastore: Datastore + + init { + datastore = when (type) { + "inmemory-emulator" -> { + val localDatastoreHelper = LocalDatastoreHelper.create(1.0) + localDatastoreHelper.start() + localDatastoreHelper.options + } + "emulator" -> { + // When prime running in GCP by hosted CI/CD, Datastore client library assumes it is running in + // production and ignore our instruction to connect to the datastore emulator. So, we are explicitly + // connecting to emulator + // logger.info("Connecting to datastore emulator") + DatastoreOptions + .newBuilder() + .setHost("localhost:9090") + .setCredentials(NoCredentials.getInstance()) + .setTransportOptions(HttpTransportOptions.newBuilder().build()) + .build() + } + else -> { + // logger.info("Created default instance of datastore client") + DatastoreOptions + .newBuilder() + .setNamespace(namespace) + .build() + } + }.service + keyFactory = datastore.newKeyFactory().setKind(entityClass.name) + } + + fun fetch(key: Key): Either = Try { + val fullEntity = datastore.fetch(key).single() + val map = fullEntity + .names + .map { name -> + val value = when (entityClass.getDeclaredField(name).type) { + Key::class.java -> fullEntity.getKey(name) + Long::class.java -> fullEntity.getLong(name) + Blob::class.java -> fullEntity.getBlob(name) + Double::class.java -> fullEntity.getDouble(name) + Boolean::class.java -> fullEntity.getBoolean(name) + LatLng::class.java -> fullEntity.getLatLng(name) + String::class.java -> fullEntity.getString(name) + StringValue::class.java -> fullEntity.getValue(name).get() + Timestamp::class.java -> fullEntity.getTimestamp(name) + else -> null + } + Pair(name, value) + } + .toMap() + objectMapper.convertValue(map, entityClass) + }.toEither() + + fun add(target: T): Either = Try { + // convert object to map of (field name, field value) + // TODO: Fails to serialize datastore 'Value<*>' types such as 'StringValue'. + val map: Map = objectMapper.convertValue(target, object : TypeReference>() {}) + + // Entity Builder + val entity = FullEntity.newBuilder(keyFactory.newKey()) + + val fieldsToExclude = fieldsToExcludeFromIndex(target) + val excludeFromIndex: (String) -> Boolean = { x -> fieldsToExclude.get(x) == true } + + // for each field, call appropriate setter + // TODO: Add support for 'datastore-exclude-from-index' annotation for other types + map.forEach { key, value -> + when (value) { + null -> entity.set(key, NullValue.of()) + is Key -> entity.set(key, value) + is Long -> entity.set(key, value) + is Blob -> entity.set(key, value) + is Double -> entity.set(key, value) + is Boolean -> entity.set(key, value) + is LatLng -> entity.set(key, value) + is String -> { + if (excludeFromIndex(key)) + entity.set(key, StringValue.newBuilder(value) + .setExcludeFromIndexes(true) + .build()) + else + entity.set(key, value) + } + is Timestamp -> entity.set(key, value) + } + } + + datastore.add(entity.build()).key + }.toEither() + + private fun fieldsToExcludeFromIndex(target: Any?): Map { + if (target == null) return mapOf() + + val map = mutableMapOf() + val declaredFields = target::class.java.declaredFields + + declaredFields.forEach { field -> + field.annotations.forEach { + when (it) { + is DatastoreExcludeFromIndex -> map.put(field.name, true) + else -> map.put(field.name, false) + } + } + } + + return map.toMap() + } +} + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class DatastoreExcludeFromIndex diff --git a/data-store/src/test/kotlin/org/ostelco/prime/store/datastore/SchemaTest.kt b/data-store/src/test/kotlin/org/ostelco/prime/store/datastore/SchemaTest.kt new file mode 100644 index 000000000..0db1d396f --- /dev/null +++ b/data-store/src/test/kotlin/org/ostelco/prime/store/datastore/SchemaTest.kt @@ -0,0 +1,53 @@ +package org.ostelco.prime.store.datastore + +import arrow.core.getOrElse +import org.junit.Test +import java.time.Instant +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +data class TestData( + val id: String, + @DatastoreExcludeFromIndex + val name: String, + val created: Long) + + +class SchemaTest { + + @Test + fun `test save and fetch from data store`() { + + val testDataStore = EntityStore(entityClass = TestData::class.java) + + val testData = TestData( + id = UUID.randomUUID().toString(), + name = "Foo", + created = Instant.now().toEpochMilli()) + + val key = testDataStore.add(testData).getOrElse { null } + assertNotNull(key) + + val fetched = testDataStore.fetch(key = key).getOrElse { null } + assertNotNull(fetched) + assertEquals(expected = testData, actual = fetched) + } + + @Test + fun `test save and fetch of long strings from data store`() { + val testDataStore = EntityStore(entityClass = TestData::class.java) + + val testData = TestData( + id = UUID.randomUUID().toString(), + name = "Foo".repeat(1000), + created = Instant.now().toEpochMilli()) + + val key = testDataStore.add(testData).getOrElse { null } + assertNotNull(key) + + val fetched = testDataStore.fetch(key = key).getOrElse { null } + assertNotNull(fetched) + assertEquals(expected = testData, actual = fetched) + } +} \ No newline at end of file diff --git a/dataflow-pipelines/Dockerfile b/dataflow-pipelines/Dockerfile index 4827fd238..73569d11e 100644 --- a/dataflow-pipelines/Dockerfile +++ b/dataflow-pipelines/Dockerfile @@ -1,6 +1,6 @@ FROM azul/zulu-openjdk:8u181-8.31.0.1 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" COPY script/start.sh /start.sh diff --git a/dataflow-pipelines/README.md b/dataflow-pipelines/README.md index 06b484ac9..5434a2051 100644 --- a/dataflow-pipelines/README.md +++ b/dataflow-pipelines/README.md @@ -3,7 +3,7 @@ ## Setup 1. In Google Cloud Platform, need to setup a project with PubSubIO, Dataflow and BigQueryIO. -2. Keep the `pantel-prod.json` auth file in config folder. +2. Keep the `prime-service-account.json` auth file in config folder. ## Package diff --git a/dataflow-pipelines/build.gradle b/dataflow-pipelines/build.gradle index 9f6985a36..f169bc334 100644 --- a/dataflow-pipelines/build.gradle +++ b/dataflow-pipelines/build.gradle @@ -1,7 +1,7 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "application" - id "com.github.johnrengelman.shadow" version "4.0.1" + id "com.github.johnrengelman.shadow" version "5.0.0" id "idea" } @@ -46,4 +46,4 @@ test { } } -apply from: '../jacoco.gradle' \ No newline at end of file +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/dataflow-pipelines/config/.gitignore b/dataflow-pipelines/config/.gitignore index bf045303f..3b858adea 100644 --- a/dataflow-pipelines/config/.gitignore +++ b/dataflow-pipelines/config/.gitignore @@ -1 +1 @@ -pantel-prod.json \ No newline at end of file +prime-service-account.json \ No newline at end of file diff --git a/dataflow-pipelines/docker-compose-dev.yaml b/dataflow-pipelines/docker-compose-dev.yaml index 90a458535..7d420a807 100644 --- a/dataflow-pipelines/docker-compose-dev.yaml +++ b/dataflow-pipelines/docker-compose-dev.yaml @@ -5,8 +5,8 @@ services: container_name: dataflow-pipelines build: . environment: - - GOOGLE_APPLICATION_CREDENTIALS=/config/pantel-prod.json - - PROJECT=pantel-2decb + - GOOGLE_APPLICATION_CREDENTIALS=/config/prime-service-account.json + - PROJECT=${GCP_PROJECT_ID} - JOB_NAME=data-traffic-dev - PUBSUB_TOPIC=data-traffic-dev - DATASET=data_consumption_dev diff --git a/dataflow-pipelines/docker-compose.yaml b/dataflow-pipelines/docker-compose.yaml index 6cadccabf..3d9f1a7cf 100644 --- a/dataflow-pipelines/docker-compose.yaml +++ b/dataflow-pipelines/docker-compose.yaml @@ -5,8 +5,8 @@ services: container_name: dataflow-pipelines build: . environment: - - GOOGLE_APPLICATION_CREDENTIALS=/config/pantel-prod.json - - PROJECT=pantel-2decb + - GOOGLE_APPLICATION_CREDENTIALS=/config/prime-service-account.json + - PROJECT=${GCP_PROJECT_ID} - JOB_NAME=data-traffic - PUBSUB_TOPIC=data-traffic - DATASET=data_consumption diff --git a/dataflow-pipelines/src/main/resources/table_schema.ddl b/dataflow-pipelines/src/main/resources/table_schema.ddl index fcf0fb19e..12a29ae87 100644 --- a/dataflow-pipelines/src/main/resources/table_schema.ddl +++ b/dataflow-pipelines/src/main/resources/table_schema.ddl @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS -`pantel-2decb.data_consumption.hourly_consumption` +`GCP_PROJECT_ID.data_consumption.hourly_consumption` ( msisdn STRING NOT NULL, bytes INT64 NOT NULL, @@ -11,7 +11,7 @@ PARTITION BY DATE(timestamp); CREATE TABLE IF NOT EXISTS -`pantel-2decb.data_consumption.raw_consumption` +`GCP_PROJECT_ID.data_consumption.raw_consumption` ( msisdn STRING NOT NULL, bucketBytes INT64 NOT NULL, @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS PARTITION BY DATE(timestamp); CREATE TABLE IF NOT EXISTS -`pantel-2decb.data_consumption_dev.hourly_consumption` +`GCP_PROJECT_ID.data_consumption_dev.hourly_consumption` ( msisdn STRING NOT NULL, bytes INT64 NOT NULL, @@ -35,7 +35,7 @@ PARTITION BY DATE(timestamp); CREATE TABLE IF NOT EXISTS -`pantel-2decb.data_consumption_dev.raw_consumption` +`GCP_PROJECT_ID.data_consumption_dev.raw_consumption` ( msisdn STRING NOT NULL, bucketBytes INT64 NOT NULL, diff --git a/diameter-ha/README.md b/diameter-ha/README.md new file mode 100644 index 000000000..79b3fca7e --- /dev/null +++ b/diameter-ha/README.md @@ -0,0 +1,76 @@ +jDiameter HA module +=================== + +This is an implementation of the jDiameter ISessionDatasource and ITimerFacility to be used for HA setup od the jDiameter +stack. To enable this you need to enable the extension in the jDiameter configuration file. + +``` + + + ... + + + + + + + +``` + +RedisReplicatedSessionDatasource +================================ + +This SessionDatasource is using Redis for storage of session information. +The Redis backend is set in the environment variables : + +* REDIS_HOSTNAME : for the host. ( Default : localhost ) +* REDIS_PORT : for port. ( Default : 6379 ) + + +It uses Redis for ICCASessionData, all other session data will use the LocalSessionData without remote storage. +This is because our OCS only use the Diameter Credit-Control-Application (CCA) + +ReplicatedTimerFacilityImpl +=========================== + +For the Diameter Credit-Control-Application server there is mainly one timer that is set for a session. This is the Tcc timer. + +The use of the Tcc Timer is defined in rfc4006 : + +``` +The supervision session timer Tcc is used in the credit-control server to supervise the credit-control session. + +... + +Session supervision timer Tcc expired - Release reserved units + +... +the session supervision timer Tcc MAY be set to two times the value of +the Validity-Time. Since credit-control +update requests are also produced at the expiry of granted service +units and/or for mid-session service events, the omission of +Validity-Time does not mean that intermediate interrogation for the +purpose of credit-control is not performed. + +``` + +jDiameter requires us to be able to store this timer and in the original implementation this was shared in the cluster. +This replication of the timer is not implemented here, as we are just running the server there is no message to send to the +client when it times out. We should make sure to release reserved units though. + + +Testing +======= + +There is a jUnit test for this module in [OCSgw](../ocsgw/src/test/java/org/ostelco/ocsgw/OcsHATest.java). +This test requires Redis to be running. +To run Redis locally for this test you can use the Redis docker container: + +``` + docker run -d --name redis-test -p 6379:6379 -v /ostelco-core/diameter-ha/config/redis.conf:/redis.conf redis redis-server /redis.conf +``` +To browse Redis one can use the redis-browser + +``` + docker run --rm -ti -p 5001:5001 --link redis-test:redis-test marian/rebrow +``` \ No newline at end of file diff --git a/diameter-ha/build.gradle b/diameter-ha/build.gradle new file mode 100644 index 000000000..292d6df8e --- /dev/null +++ b/diameter-ha/build.gradle @@ -0,0 +1,43 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "java-library" + id "maven" +} + +version = "1.0.0-SNAPSHOT" + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + api('org.mobicents.diameter:jdiameter-api:1.7.1-123') { + exclude module: 'netty-all' + } + api('org.mobicents.diameter:jdiameter-impl:1.7.1-123') { + exclude module: 'netty-all' + exclude group: 'org.slf4j', module: 'slf4j-log4j12' + exclude group: 'log4j', module: 'log4j' + } + implementation "org.slf4j:log4j-over-slf4j:$slf4jVersion" + + compile 'io.lettuce:lettuce-core:5.1.6.RELEASE' + + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" + testRuntimeOnly 'org.hamcrest:hamcrest-all:1.3' + testImplementation 'org.mockito:mockito-all:1.10.19' +} + +task javadocJar(type: Jar) { + classifier = 'javadoc' + from javadoc +} + +task sourcesJar(type: Jar) { + classifier = 'sources' + from sourceSets.main.allSource +} + +artifacts { + archives javadocJar, sourcesJar +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/diameter-ha/config/redis.conf b/diameter-ha/config/redis.conf new file mode 100644 index 000000000..105de0ac2 --- /dev/null +++ b/diameter-ha/config/redis.conf @@ -0,0 +1,1377 @@ +# Redis configuration file example. +# +# Note that in order to read the configuration file, Redis must be +# started with the file path as first argument: +# +# ./redis-server /path/to/redis.conf + +# Note on units: when memory size is needed, it is possible to specify +# it in the usual form of 1k 5GB 4M and so forth: +# +# 1k => 1000 bytes +# 1kb => 1024 bytes +# 1m => 1000000 bytes +# 1mb => 1024*1024 bytes +# 1g => 1000000000 bytes +# 1gb => 1024*1024*1024 bytes +# +# units are case insensitive so 1GB 1Gb 1gB are all the same. + +################################## INCLUDES ################################### + +# Include one or more other config files here. This is useful if you +# have a standard template that goes to all Redis servers but also need +# to customize a few per-server settings. Include files can include +# other files, so use this wisely. +# +# Notice option "include" won't be rewritten by command "CONFIG REWRITE" +# from admin or Redis Sentinel. Since Redis always uses the last processed +# line as value of a configuration directive, you'd better put includes +# at the beginning of this file to avoid overwriting config change at runtime. +# +# If instead you are interested in using includes to override configuration +# options, it is better to use include as the last line. +# +# include /path/to/local.conf +# include /path/to/other.conf + +################################## MODULES ##################################### + +# Load modules at startup. If the server is not able to load modules +# it will abort. It is possible to use multiple loadmodule directives. +# +# loadmodule /path/to/my_module.so +# loadmodule /path/to/other_module.so + +################################## NETWORK ##################################### + +# By default, if no "bind" configuration directive is specified, Redis listens +# for connections from all the network interfaces available on the server. +# It is possible to listen to just one or multiple selected interfaces using +# the "bind" configuration directive, followed by one or more IP addresses. +# +# Examples: +# +# bind 192.168.1.100 10.0.0.1 +# bind 127.0.0.1 ::1 +# +# ~~~ WARNING ~~~ If the computer running Redis is directly exposed to the +# internet, binding to all the interfaces is dangerous and will expose the +# instance to everybody on the internet. So by default we uncomment the +# following bind directive, that will force Redis to listen only into +# the IPv4 loopback interface address (this means Redis will be able to +# accept connections only from clients running into the same computer it +# is running). +# +# IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES +# JUST COMMENT THE FOLLOWING LINE. +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +bind 0.0.0.0 + +# Protected mode is a layer of security protection, in order to avoid that +# Redis instances left open on the internet are accessed and exploited. +# +# When protected mode is on and if: +# +# 1) The server is not binding explicitly to a set of addresses using the +# "bind" directive. +# 2) No password is configured. +# +# The server only accepts connections from clients connecting from the +# IPv4 and IPv6 loopback addresses 127.0.0.1 and ::1, and from Unix domain +# sockets. +# +# By default protected mode is enabled. You should disable it only if +# you are sure you want clients from other hosts to connect to Redis +# even if no authentication is configured, nor a specific set of interfaces +# are explicitly listed using the "bind" directive. +protected-mode yes + +# Accept connections on the specified port, default is 6379 (IANA #815344). +# If port 0 is specified Redis will not listen on a TCP socket. +port 6379 + +# TCP listen() backlog. +# +# In high requests-per-second environments you need an high backlog in order +# to avoid slow clients connections issues. Note that the Linux kernel +# will silently truncate it to the value of /proc/sys/net/core/somaxconn so +# make sure to raise both the value of somaxconn and tcp_max_syn_backlog +# in order to get the desired effect. +tcp-backlog 511 + +# Unix socket. +# +# Specify the path for the Unix socket that will be used to listen for +# incoming connections. There is no default, so Redis will not listen +# on a unix socket when not specified. +# +# unixsocket /tmp/redis.sock +# unixsocketperm 700 + +# Close the connection after a client is idle for N seconds (0 to disable) +timeout 0 + +# TCP keepalive. +# +# If non-zero, use SO_KEEPALIVE to send TCP ACKs to clients in absence +# of communication. This is useful for two reasons: +# +# 1) Detect dead peers. +# 2) Take the connection alive from the point of view of network +# equipment in the middle. +# +# On Linux, the specified value (in seconds) is the period used to send ACKs. +# Note that to close the connection the double of the time is needed. +# On other kernels the period depends on the kernel configuration. +# +# A reasonable value for this option is 300 seconds, which is the new +# Redis default starting with Redis 3.2.1. +tcp-keepalive 300 + +################################# GENERAL ##################################### + +# By default Redis does not run as a daemon. Use 'yes' if you need it. +# Note that Redis will write a pid file in /var/run/redis.pid when daemonized. +daemonize no + +# If you run Redis from upstart or systemd, Redis can interact with your +# supervision tree. Options: +# supervised no - no supervision interaction +# supervised upstart - signal upstart by putting Redis into SIGSTOP mode +# supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET +# supervised auto - detect upstart or systemd method based on +# UPSTART_JOB or NOTIFY_SOCKET environment variables +# Note: these supervision methods only signal "process is ready." +# They do not enable continuous liveness pings back to your supervisor. +supervised no + +# If a pid file is specified, Redis writes it where specified at startup +# and removes it at exit. +# +# When the server runs non daemonized, no pid file is created if none is +# specified in the configuration. When the server is daemonized, the pid file +# is used even if not specified, defaulting to "/var/run/redis.pid". +# +# Creating a pid file is best effort: if Redis is not able to create it +# nothing bad happens, the server will start and run normally. +pidfile /var/run/redis_6379.pid + +# Specify the server verbosity level. +# This can be one of: +# debug (a lot of information, useful for development/testing) +# verbose (many rarely useful info, but not a mess like the debug level) +# notice (moderately verbose, what you want in production probably) +# warning (only very important / critical messages are logged) +loglevel notice + +# Specify the log file name. Also the empty string can be used to force +# Redis to log on the standard output. Note that if you use standard +# output for logging but daemonize, logs will be sent to /dev/null +logfile "" + +# To enable logging to the system logger, just set 'syslog-enabled' to yes, +# and optionally update the other syslog parameters to suit your needs. +# syslog-enabled no + +# Specify the syslog identity. +# syslog-ident redis + +# Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7. +# syslog-facility local0 + +# Set the number of databases. The default database is DB 0, you can select +# a different one on a per-connection basis using SELECT where +# dbid is a number between 0 and 'databases'-1 +databases 16 + +# By default Redis shows an ASCII art logo only when started to log to the +# standard output and if the standard output is a TTY. Basically this means +# that normally a logo is displayed only in interactive sessions. +# +# However it is possible to force the pre-4.0 behavior and always show a +# ASCII art logo in startup logs by setting the following option to yes. +always-show-logo yes + +################################ SNAPSHOTTING ################################ +# +# Save the DB on disk: +# +# save +# +# Will save the DB if both the given number of seconds and the given +# number of write operations against the DB occurred. +# +# In the example below the behaviour will be to save: +# after 900 sec (15 min) if at least 1 key changed +# after 300 sec (5 min) if at least 10 keys changed +# after 60 sec if at least 10000 keys changed +# +# Note: you can disable saving completely by commenting out all "save" lines. +# +# It is also possible to remove all the previously configured save +# points by adding a save directive with a single empty string argument +# like in the following example: +# +# save "" + +save 900 1 +save 300 10 +save 60 10000 + +# By default Redis will stop accepting writes if RDB snapshots are enabled +# (at least one save point) and the latest background save failed. +# This will make the user aware (in a hard way) that data is not persisting +# on disk properly, otherwise chances are that no one will notice and some +# disaster will happen. +# +# If the background saving process will start working again Redis will +# automatically allow writes again. +# +# However if you have setup your proper monitoring of the Redis server +# and persistence, you may want to disable this feature so that Redis will +# continue to work as usual even if there are problems with disk, +# permissions, and so forth. +stop-writes-on-bgsave-error yes + +# Compress string objects using LZF when dump .rdb databases? +# For default that's set to 'yes' as it's almost always a win. +# If you want to save some CPU in the saving child set it to 'no' but +# the dataset will likely be bigger if you have compressible values or keys. +rdbcompression yes + +# Since version 5 of RDB a CRC64 checksum is placed at the end of the file. +# This makes the format more resistant to corruption but there is a performance +# hit to pay (around 10%) when saving and loading RDB files, so you can disable it +# for maximum performances. +# +# RDB files created with checksum disabled have a checksum of zero that will +# tell the loading code to skip the check. +rdbchecksum yes + +# The filename where to dump the DB +dbfilename dump.rdb + +# The working directory. +# +# The DB will be written inside this directory, with the filename specified +# above using the 'dbfilename' configuration directive. +# +# The Append Only File will also be created inside this directory. +# +# Note that you must specify a directory here, not a file name. +dir ./ + +################################# REPLICATION ################################# + +# Master-Replica replication. Use replicaof to make a Redis instance a copy of +# another Redis server. A few things to understand ASAP about Redis replication. +# +# +------------------+ +---------------+ +# | Master | ---> | Replica | +# | (receive writes) | | (exact copy) | +# +------------------+ +---------------+ +# +# 1) Redis replication is asynchronous, but you can configure a master to +# stop accepting writes if it appears to be not connected with at least +# a given number of replicas. +# 2) Redis replicas are able to perform a partial resynchronization with the +# master if the replication link is lost for a relatively small amount of +# time. You may want to configure the replication backlog size (see the next +# sections of this file) with a sensible value depending on your needs. +# 3) Replication is automatic and does not need user intervention. After a +# network partition replicas automatically try to reconnect to masters +# and resynchronize with them. +# +# replicaof + +# If the master is password protected (using the "requirepass" configuration +# directive below) it is possible to tell the replica to authenticate before +# starting the replication synchronization process, otherwise the master will +# refuse the replica request. +# +# masterauth + +# When a replica loses its connection with the master, or when the replication +# is still in progress, the replica can act in two different ways: +# +# 1) if replica-serve-stale-data is set to 'yes' (the default) the replica will +# still reply to client requests, possibly with out of date data, or the +# data set may just be empty if this is the first synchronization. +# +# 2) if replica-serve-stale-data is set to 'no' the replica will reply with +# an error "SYNC with master in progress" to all the kind of commands +# but to INFO, replicaOF, AUTH, PING, SHUTDOWN, REPLCONF, ROLE, CONFIG, +# SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB, +# COMMAND, POST, HOST: and LATENCY. +# +replica-serve-stale-data yes + +# You can configure a replica instance to accept writes or not. Writing against +# a replica instance may be useful to store some ephemeral data (because data +# written on a replica will be easily deleted after resync with the master) but +# may also cause problems if clients are writing to it because of a +# misconfiguration. +# +# Since Redis 2.6 by default replicas are read-only. +# +# Note: read only replicas are not designed to be exposed to untrusted clients +# on the internet. It's just a protection layer against misuse of the instance. +# Still a read only replica exports by default all the administrative commands +# such as CONFIG, DEBUG, and so forth. To a limited extent you can improve +# security of read only replicas using 'rename-command' to shadow all the +# administrative / dangerous commands. +replica-read-only yes + +# Replication SYNC strategy: disk or socket. +# +# ------------------------------------------------------- +# WARNING: DISKLESS REPLICATION IS EXPERIMENTAL CURRENTLY +# ------------------------------------------------------- +# +# New replicas and reconnecting replicas that are not able to continue the replication +# process just receiving differences, need to do what is called a "full +# synchronization". An RDB file is transmitted from the master to the replicas. +# The transmission can happen in two different ways: +# +# 1) Disk-backed: The Redis master creates a new process that writes the RDB +# file on disk. Later the file is transferred by the parent +# process to the replicas incrementally. +# 2) Diskless: The Redis master creates a new process that directly writes the +# RDB file to replica sockets, without touching the disk at all. +# +# With disk-backed replication, while the RDB file is generated, more replicas +# can be queued and served with the RDB file as soon as the current child producing +# the RDB file finishes its work. With diskless replication instead once +# the transfer starts, new replicas arriving will be queued and a new transfer +# will start when the current one terminates. +# +# When diskless replication is used, the master waits a configurable amount of +# time (in seconds) before starting the transfer in the hope that multiple replicas +# will arrive and the transfer can be parallelized. +# +# With slow disks and fast (large bandwidth) networks, diskless replication +# works better. +repl-diskless-sync no + +# When diskless replication is enabled, it is possible to configure the delay +# the server waits in order to spawn the child that transfers the RDB via socket +# to the replicas. +# +# This is important since once the transfer starts, it is not possible to serve +# new replicas arriving, that will be queued for the next RDB transfer, so the server +# waits a delay in order to let more replicas arrive. +# +# The delay is specified in seconds, and by default is 5 seconds. To disable +# it entirely just set it to 0 seconds and the transfer will start ASAP. +repl-diskless-sync-delay 5 + +# Replicas send PINGs to server in a predefined interval. It's possible to change +# this interval with the repl_ping_replica_period option. The default value is 10 +# seconds. +# +# repl-ping-replica-period 10 + +# The following option sets the replication timeout for: +# +# 1) Bulk transfer I/O during SYNC, from the point of view of replica. +# 2) Master timeout from the point of view of replicas (data, pings). +# 3) Replica timeout from the point of view of masters (REPLCONF ACK pings). +# +# It is important to make sure that this value is greater than the value +# specified for repl-ping-replica-period otherwise a timeout will be detected +# every time there is low traffic between the master and the replica. +# +# repl-timeout 60 + +# Disable TCP_NODELAY on the replica socket after SYNC? +# +# If you select "yes" Redis will use a smaller number of TCP packets and +# less bandwidth to send data to replicas. But this can add a delay for +# the data to appear on the replica side, up to 40 milliseconds with +# Linux kernels using a default configuration. +# +# If you select "no" the delay for data to appear on the replica side will +# be reduced but more bandwidth will be used for replication. +# +# By default we optimize for low latency, but in very high traffic conditions +# or when the master and replicas are many hops away, turning this to "yes" may +# be a good idea. +repl-disable-tcp-nodelay no + +# Set the replication backlog size. The backlog is a buffer that accumulates +# replica data when replicas are disconnected for some time, so that when a replica +# wants to reconnect again, often a full resync is not needed, but a partial +# resync is enough, just passing the portion of data the replica missed while +# disconnected. +# +# The bigger the replication backlog, the longer the time the replica can be +# disconnected and later be able to perform a partial resynchronization. +# +# The backlog is only allocated once there is at least a replica connected. +# +# repl-backlog-size 1mb + +# After a master has no longer connected replicas for some time, the backlog +# will be freed. The following option configures the amount of seconds that +# need to elapse, starting from the time the last replica disconnected, for +# the backlog buffer to be freed. +# +# Note that replicas never free the backlog for timeout, since they may be +# promoted to masters later, and should be able to correctly "partially +# resynchronize" with the replicas: hence they should always accumulate backlog. +# +# A value of 0 means to never release the backlog. +# +# repl-backlog-ttl 3600 + +# The replica priority is an integer number published by Redis in the INFO output. +# It is used by Redis Sentinel in order to select a replica to promote into a +# master if the master is no longer working correctly. +# +# A replica with a low priority number is considered better for promotion, so +# for instance if there are three replicas with priority 10, 100, 25 Sentinel will +# pick the one with priority 10, that is the lowest. +# +# However a special priority of 0 marks the replica as not able to perform the +# role of master, so a replica with priority of 0 will never be selected by +# Redis Sentinel for promotion. +# +# By default the priority is 100. +replica-priority 100 + +# It is possible for a master to stop accepting writes if there are less than +# N replicas connected, having a lag less or equal than M seconds. +# +# The N replicas need to be in "online" state. +# +# The lag in seconds, that must be <= the specified value, is calculated from +# the last ping received from the replica, that is usually sent every second. +# +# This option does not GUARANTEE that N replicas will accept the write, but +# will limit the window of exposure for lost writes in case not enough replicas +# are available, to the specified number of seconds. +# +# For example to require at least 3 replicas with a lag <= 10 seconds use: +# +# min-replicas-to-write 3 +# min-replicas-max-lag 10 +# +# Setting one or the other to 0 disables the feature. +# +# By default min-replicas-to-write is set to 0 (feature disabled) and +# min-replicas-max-lag is set to 10. + +# A Redis master is able to list the address and port of the attached +# replicas in different ways. For example the "INFO replication" section +# offers this information, which is used, among other tools, by +# Redis Sentinel in order to discover replica instances. +# Another place where this info is available is in the output of the +# "ROLE" command of a master. +# +# The listed IP and address normally reported by a replica is obtained +# in the following way: +# +# IP: The address is auto detected by checking the peer address +# of the socket used by the replica to connect with the master. +# +# Port: The port is communicated by the replica during the replication +# handshake, and is normally the port that the replica is using to +# listen for connections. +# +# However when port forwarding or Network Address Translation (NAT) is +# used, the replica may be actually reachable via different IP and port +# pairs. The following two options can be used by a replica in order to +# report to its master a specific set of IP and port, so that both INFO +# and ROLE will report those values. +# +# There is no need to use both the options if you need to override just +# the port or the IP address. +# +# replica-announce-ip 5.5.5.5 +# replica-announce-port 1234 + +################################## SECURITY ################################### + +# Require clients to issue AUTH before processing any other +# commands. This might be useful in environments in which you do not trust +# others with access to the host running redis-server. +# +# This should stay commented out for backward compatibility and because most +# people do not need auth (e.g. they run their own servers). +# +# Warning: since Redis is pretty fast an outside user can try up to +# 150k passwords per second against a good box. This means that you should +# use a very strong password otherwise it will be very easy to break. +# +# requirepass foobared + +# Command renaming. +# +# It is possible to change the name of dangerous commands in a shared +# environment. For instance the CONFIG command may be renamed into something +# hard to guess so that it will still be available for internal-use tools +# but not available for general clients. +# +# Example: +# +# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52 +# +# It is also possible to completely kill a command by renaming it into +# an empty string: +# +# rename-command CONFIG "" +# +# Please note that changing the name of commands that are logged into the +# AOF file or transmitted to replicas may cause problems. + +################################### CLIENTS #################################### + +# Set the max number of connected clients at the same time. By default +# this limit is set to 10000 clients, however if the Redis server is not +# able to configure the process file limit to allow for the specified limit +# the max number of allowed clients is set to the current file limit +# minus 32 (as Redis reserves a few file descriptors for internal uses). +# +# Once the limit is reached Redis will close all the new connections sending +# an error 'max number of clients reached'. +# +# maxclients 10000 + +############################## MEMORY MANAGEMENT ################################ + +# Set a memory usage limit to the specified amount of bytes. +# When the memory limit is reached Redis will try to remove keys +# according to the eviction policy selected (see maxmemory-policy). +# +# If Redis can't remove keys according to the policy, or if the policy is +# set to 'noeviction', Redis will start to reply with errors to commands +# that would use more memory, like SET, LPUSH, and so on, and will continue +# to reply to read-only commands like GET. +# +# This option is usually useful when using Redis as an LRU or LFU cache, or to +# set a hard memory limit for an instance (using the 'noeviction' policy). +# +# WARNING: If you have replicas attached to an instance with maxmemory on, +# the size of the output buffers needed to feed the replicas are subtracted +# from the used memory count, so that network problems / resyncs will +# not trigger a loop where keys are evicted, and in turn the output +# buffer of replicas is full with DELs of keys evicted triggering the deletion +# of more keys, and so forth until the database is completely emptied. +# +# In short... if you have replicas attached it is suggested that you set a lower +# limit for maxmemory so that there is some free RAM on the system for replica +# output buffers (but this is not needed if the policy is 'noeviction'). +# +# maxmemory + +# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory +# is reached. You can select among five behaviors: +# +# volatile-lru -> Evict using approximated LRU among the keys with an expire set. +# allkeys-lru -> Evict any key using approximated LRU. +# volatile-lfu -> Evict using approximated LFU among the keys with an expire set. +# allkeys-lfu -> Evict any key using approximated LFU. +# volatile-random -> Remove a random key among the ones with an expire set. +# allkeys-random -> Remove a random key, any key. +# volatile-ttl -> Remove the key with the nearest expire time (minor TTL) +# noeviction -> Don't evict anything, just return an error on write operations. +# +# LRU means Least Recently Used +# LFU means Least Frequently Used +# +# Both LRU, LFU and volatile-ttl are implemented using approximated +# randomized algorithms. +# +# Note: with any of the above policies, Redis will return an error on write +# operations, when there are no suitable keys for eviction. +# +# At the date of writing these commands are: set setnx setex append +# incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd +# sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby +# zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby +# getset mset msetnx exec sort +# +# The default is: +# +# maxmemory-policy noeviction + +# LRU, LFU and minimal TTL algorithms are not precise algorithms but approximated +# algorithms (in order to save memory), so you can tune it for speed or +# accuracy. For default Redis will check five keys and pick the one that was +# used less recently, you can change the sample size using the following +# configuration directive. +# +# The default of 5 produces good enough results. 10 Approximates very closely +# true LRU but costs more CPU. 3 is faster but not very accurate. +# +# maxmemory-samples 5 + +# Starting from Redis 5, by default a replica will ignore its maxmemory setting +# (unless it is promoted to master after a failover or manually). It means +# that the eviction of keys will be just handled by the master, sending the +# DEL commands to the replica as keys evict in the master side. +# +# This behavior ensures that masters and replicas stay consistent, and is usually +# what you want, however if your replica is writable, or you want the replica to have +# a different memory setting, and you are sure all the writes performed to the +# replica are idempotent, then you may change this default (but be sure to understand +# what you are doing). +# +# Note that since the replica by default does not evict, it may end using more +# memory than the one set via maxmemory (there are certain buffers that may +# be larger on the replica, or data structures may sometimes take more memory and so +# forth). So make sure you monitor your replicas and make sure they have enough +# memory to never hit a real out-of-memory condition before the master hits +# the configured maxmemory setting. +# +# replica-ignore-maxmemory yes + +############################# LAZY FREEING #################################### + +# Redis has two primitives to delete keys. One is called DEL and is a blocking +# deletion of the object. It means that the server stops processing new commands +# in order to reclaim all the memory associated with an object in a synchronous +# way. If the key deleted is associated with a small object, the time needed +# in order to execute the DEL command is very small and comparable to most other +# O(1) or O(log_N) commands in Redis. However if the key is associated with an +# aggregated value containing millions of elements, the server can block for +# a long time (even seconds) in order to complete the operation. +# +# For the above reasons Redis also offers non blocking deletion primitives +# such as UNLINK (non blocking DEL) and the ASYNC option of FLUSHALL and +# FLUSHDB commands, in order to reclaim memory in background. Those commands +# are executed in constant time. Another thread will incrementally free the +# object in the background as fast as possible. +# +# DEL, UNLINK and ASYNC option of FLUSHALL and FLUSHDB are user-controlled. +# It's up to the design of the application to understand when it is a good +# idea to use one or the other. However the Redis server sometimes has to +# delete keys or flush the whole database as a side effect of other operations. +# Specifically Redis deletes objects independently of a user call in the +# following scenarios: +# +# 1) On eviction, because of the maxmemory and maxmemory policy configurations, +# in order to make room for new data, without going over the specified +# memory limit. +# 2) Because of expire: when a key with an associated time to live (see the +# EXPIRE command) must be deleted from memory. +# 3) Because of a side effect of a command that stores data on a key that may +# already exist. For example the RENAME command may delete the old key +# content when it is replaced with another one. Similarly SUNIONSTORE +# or SORT with STORE option may delete existing keys. The SET command +# itself removes any old content of the specified key in order to replace +# it with the specified string. +# 4) During replication, when a replica performs a full resynchronization with +# its master, the content of the whole database is removed in order to +# load the RDB file just transferred. +# +# In all the above cases the default is to delete objects in a blocking way, +# like if DEL was called. However you can configure each case specifically +# in order to instead release memory in a non-blocking way like if UNLINK +# was called, using the following configuration directives: + +lazyfree-lazy-eviction no +lazyfree-lazy-expire no +lazyfree-lazy-server-del no +replica-lazy-flush no + +############################## APPEND ONLY MODE ############################### + +# By default Redis asynchronously dumps the dataset on disk. This mode is +# good enough in many applications, but an issue with the Redis process or +# a power outage may result into a few minutes of writes lost (depending on +# the configured save points). +# +# The Append Only File is an alternative persistence mode that provides +# much better durability. For instance using the default data fsync policy +# (see later in the config file) Redis can lose just one second of writes in a +# dramatic event like a server power outage, or a single write if something +# wrong with the Redis process itself happens, but the operating system is +# still running correctly. +# +# AOF and RDB persistence can be enabled at the same time without problems. +# If the AOF is enabled on startup Redis will load the AOF, that is the file +# with the better durability guarantees. +# +# Please check http://redis.io/topics/persistence for more information. + +appendonly no + +# The name of the append only file (default: "appendonly.aof") + +appendfilename "appendonly.aof" + +# The fsync() call tells the Operating System to actually write data on disk +# instead of waiting for more data in the output buffer. Some OS will really flush +# data on disk, some other OS will just try to do it ASAP. +# +# Redis supports three different modes: +# +# no: don't fsync, just let the OS flush the data when it wants. Faster. +# always: fsync after every write to the append only log. Slow, Safest. +# everysec: fsync only one time every second. Compromise. +# +# The default is "everysec", as that's usually the right compromise between +# speed and data safety. It's up to you to understand if you can relax this to +# "no" that will let the operating system flush the output buffer when +# it wants, for better performances (but if you can live with the idea of +# some data loss consider the default persistence mode that's snapshotting), +# or on the contrary, use "always" that's very slow but a bit safer than +# everysec. +# +# More details please check the following article: +# http://antirez.com/post/redis-persistence-demystified.html +# +# If unsure, use "everysec". + +# appendfsync always +appendfsync everysec +# appendfsync no + +# When the AOF fsync policy is set to always or everysec, and a background +# saving process (a background save or AOF log background rewriting) is +# performing a lot of I/O against the disk, in some Linux configurations +# Redis may block too long on the fsync() call. Note that there is no fix for +# this currently, as even performing fsync in a different thread will block +# our synchronous write(2) call. +# +# In order to mitigate this problem it's possible to use the following option +# that will prevent fsync() from being called in the main process while a +# BGSAVE or BGREWRITEAOF is in progress. +# +# This means that while another child is saving, the durability of Redis is +# the same as "appendfsync none". In practical terms, this means that it is +# possible to lose up to 30 seconds of log in the worst scenario (with the +# default Linux settings). +# +# If you have latency problems turn this to "yes". Otherwise leave it as +# "no" that is the safest pick from the point of view of durability. + +no-appendfsync-on-rewrite no + +# Automatic rewrite of the append only file. +# Redis is able to automatically rewrite the log file implicitly calling +# BGREWRITEAOF when the AOF log size grows by the specified percentage. +# +# This is how it works: Redis remembers the size of the AOF file after the +# latest rewrite (if no rewrite has happened since the restart, the size of +# the AOF at startup is used). +# +# This base size is compared to the current size. If the current size is +# bigger than the specified percentage, the rewrite is triggered. Also +# you need to specify a minimal size for the AOF file to be rewritten, this +# is useful to avoid rewriting the AOF file even if the percentage increase +# is reached but it is still pretty small. +# +# Specify a percentage of zero in order to disable the automatic AOF +# rewrite feature. + +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb + +# An AOF file may be found to be truncated at the end during the Redis +# startup process, when the AOF data gets loaded back into memory. +# This may happen when the system where Redis is running +# crashes, especially when an ext4 filesystem is mounted without the +# data=ordered option (however this can't happen when Redis itself +# crashes or aborts but the operating system still works correctly). +# +# Redis can either exit with an error when this happens, or load as much +# data as possible (the default now) and start if the AOF file is found +# to be truncated at the end. The following option controls this behavior. +# +# If aof-load-truncated is set to yes, a truncated AOF file is loaded and +# the Redis server starts emitting a log to inform the user of the event. +# Otherwise if the option is set to no, the server aborts with an error +# and refuses to start. When the option is set to no, the user requires +# to fix the AOF file using the "redis-check-aof" utility before to restart +# the server. +# +# Note that if the AOF file will be found to be corrupted in the middle +# the server will still exit with an error. This option only applies when +# Redis will try to read more data from the AOF file but not enough bytes +# will be found. +aof-load-truncated yes + +# When rewriting the AOF file, Redis is able to use an RDB preamble in the +# AOF file for faster rewrites and recoveries. When this option is turned +# on the rewritten AOF file is composed of two different stanzas: +# +# [RDB file][AOF tail] +# +# When loading Redis recognizes that the AOF file starts with the "REDIS" +# string and loads the prefixed RDB file, and continues loading the AOF +# tail. +aof-use-rdb-preamble yes + +################################ LUA SCRIPTING ############################### + +# Max execution time of a Lua script in milliseconds. +# +# If the maximum execution time is reached Redis will log that a script is +# still in execution after the maximum allowed time and will start to +# reply to queries with an error. +# +# When a long running script exceeds the maximum execution time only the +# SCRIPT KILL and SHUTDOWN NOSAVE commands are available. The first can be +# used to stop a script that did not yet called write commands. The second +# is the only way to shut down the server in the case a write command was +# already issued by the script but the user doesn't want to wait for the natural +# termination of the script. +# +# Set it to 0 or a negative value for unlimited execution without warnings. +lua-time-limit 5000 + +################################ REDIS CLUSTER ############################### +# +# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# WARNING EXPERIMENTAL: Redis Cluster is considered to be stable code, however +# in order to mark it as "mature" we need to wait for a non trivial percentage +# of users to deploy it in production. +# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# +# Normal Redis instances can't be part of a Redis Cluster; only nodes that are +# started as cluster nodes can. In order to start a Redis instance as a +# cluster node enable the cluster support uncommenting the following: +# +# cluster-enabled yes + +# Every cluster node has a cluster configuration file. This file is not +# intended to be edited by hand. It is created and updated by Redis nodes. +# Every Redis Cluster node requires a different cluster configuration file. +# Make sure that instances running in the same system do not have +# overlapping cluster configuration file names. +# +# cluster-config-file nodes-6379.conf + +# Cluster node timeout is the amount of milliseconds a node must be unreachable +# for it to be considered in failure state. +# Most other internal time limits are multiple of the node timeout. +# +# cluster-node-timeout 15000 + +# A replica of a failing master will avoid to start a failover if its data +# looks too old. +# +# There is no simple way for a replica to actually have an exact measure of +# its "data age", so the following two checks are performed: +# +# 1) If there are multiple replicas able to failover, they exchange messages +# in order to try to give an advantage to the replica with the best +# replication offset (more data from the master processed). +# Replicas will try to get their rank by offset, and apply to the start +# of the failover a delay proportional to their rank. +# +# 2) Every single replica computes the time of the last interaction with +# its master. This can be the last ping or command received (if the master +# is still in the "connected" state), or the time that elapsed since the +# disconnection with the master (if the replication link is currently down). +# If the last interaction is too old, the replica will not try to failover +# at all. +# +# The point "2" can be tuned by user. Specifically a replica will not perform +# the failover if, since the last interaction with the master, the time +# elapsed is greater than: +# +# (node-timeout * replica-validity-factor) + repl-ping-replica-period +# +# So for example if node-timeout is 30 seconds, and the replica-validity-factor +# is 10, and assuming a default repl-ping-replica-period of 10 seconds, the +# replica will not try to failover if it was not able to talk with the master +# for longer than 310 seconds. +# +# A large replica-validity-factor may allow replicas with too old data to failover +# a master, while a too small value may prevent the cluster from being able to +# elect a replica at all. +# +# For maximum availability, it is possible to set the replica-validity-factor +# to a value of 0, which means, that replicas will always try to failover the +# master regardless of the last time they interacted with the master. +# (However they'll always try to apply a delay proportional to their +# offset rank). +# +# Zero is the only value able to guarantee that when all the partitions heal +# the cluster will always be able to continue. +# +# cluster-replica-validity-factor 10 + +# Cluster replicas are able to migrate to orphaned masters, that are masters +# that are left without working replicas. This improves the cluster ability +# to resist to failures as otherwise an orphaned master can't be failed over +# in case of failure if it has no working replicas. +# +# Replicas migrate to orphaned masters only if there are still at least a +# given number of other working replicas for their old master. This number +# is the "migration barrier". A migration barrier of 1 means that a replica +# will migrate only if there is at least 1 other working replica for its master +# and so forth. It usually reflects the number of replicas you want for every +# master in your cluster. +# +# Default is 1 (replicas migrate only if their masters remain with at least +# one replica). To disable migration just set it to a very large value. +# A value of 0 can be set but is useful only for debugging and dangerous +# in production. +# +# cluster-migration-barrier 1 + +# By default Redis Cluster nodes stop accepting queries if they detect there +# is at least an hash slot uncovered (no available node is serving it). +# This way if the cluster is partially down (for example a range of hash slots +# are no longer covered) all the cluster becomes, eventually, unavailable. +# It automatically returns available as soon as all the slots are covered again. +# +# However sometimes you want the subset of the cluster which is working, +# to continue to accept queries for the part of the key space that is still +# covered. In order to do so, just set the cluster-require-full-coverage +# option to no. +# +# cluster-require-full-coverage yes + +# This option, when set to yes, prevents replicas from trying to failover its +# master during master failures. However the master can still perform a +# manual failover, if forced to do so. +# +# This is useful in different scenarios, especially in the case of multiple +# data center operations, where we want one side to never be promoted if not +# in the case of a total DC failure. +# +# cluster-replica-no-failover no + +# In order to setup your cluster make sure to read the documentation +# available at http://redis.io web site. + +########################## CLUSTER DOCKER/NAT support ######################## + +# In certain deployments, Redis Cluster nodes address discovery fails, because +# addresses are NAT-ted or because ports are forwarded (the typical case is +# Docker and other containers). +# +# In order to make Redis Cluster working in such environments, a static +# configuration where each node knows its public address is needed. The +# following two options are used for this scope, and are: +# +# * cluster-announce-ip +# * cluster-announce-port +# * cluster-announce-bus-port +# +# Each instruct the node about its address, client port, and cluster message +# bus port. The information is then published in the header of the bus packets +# so that other nodes will be able to correctly map the address of the node +# publishing the information. +# +# If the above options are not used, the normal Redis Cluster auto-detection +# will be used instead. +# +# Note that when remapped, the bus port may not be at the fixed offset of +# clients port + 10000, so you can specify any port and bus-port depending +# on how they get remapped. If the bus-port is not set, a fixed offset of +# 10000 will be used as usually. +# +# Example: +# +# cluster-announce-ip 10.1.1.5 +# cluster-announce-port 6379 +# cluster-announce-bus-port 6380 + +################################## SLOW LOG ################################### + +# The Redis Slow Log is a system to log queries that exceeded a specified +# execution time. The execution time does not include the I/O operations +# like talking with the client, sending the reply and so forth, +# but just the time needed to actually execute the command (this is the only +# stage of command execution where the thread is blocked and can not serve +# other requests in the meantime). +# +# You can configure the slow log with two parameters: one tells Redis +# what is the execution time, in microseconds, to exceed in order for the +# command to get logged, and the other parameter is the length of the +# slow log. When a new command is logged the oldest one is removed from the +# queue of logged commands. + +# The following time is expressed in microseconds, so 1000000 is equivalent +# to one second. Note that a negative number disables the slow log, while +# a value of zero forces the logging of every command. +slowlog-log-slower-than 10000 + +# There is no limit to this length. Just be aware that it will consume memory. +# You can reclaim memory used by the slow log with SLOWLOG RESET. +slowlog-max-len 128 + +################################ LATENCY MONITOR ############################## + +# The Redis latency monitoring subsystem samples different operations +# at runtime in order to collect data related to possible sources of +# latency of a Redis instance. +# +# Via the LATENCY command this information is available to the user that can +# print graphs and obtain reports. +# +# The system only logs operations that were performed in a time equal or +# greater than the amount of milliseconds specified via the +# latency-monitor-threshold configuration directive. When its value is set +# to zero, the latency monitor is turned off. +# +# By default latency monitoring is disabled since it is mostly not needed +# if you don't have latency issues, and collecting data has a performance +# impact, that while very small, can be measured under big load. Latency +# monitoring can easily be enabled at runtime using the command +# "CONFIG SET latency-monitor-threshold " if needed. +latency-monitor-threshold 0 + +############################# EVENT NOTIFICATION ############################## + +# Redis can notify Pub/Sub clients about events happening in the key space. +# This feature is documented at http://redis.io/topics/notifications +# +# For instance if keyspace events notification is enabled, and a client +# performs a DEL operation on key "foo" stored in the Database 0, two +# messages will be published via Pub/Sub: +# +# PUBLISH __keyspace@0__:foo del +# PUBLISH __keyevent@0__:del foo +# +# It is possible to select the events that Redis will notify among a set +# of classes. Every class is identified by a single character: +# +# K Keyspace events, published with __keyspace@__ prefix. +# E Keyevent events, published with __keyevent@__ prefix. +# g Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ... +# $ String commands +# l List commands +# s Set commands +# h Hash commands +# z Sorted set commands +# x Expired events (events generated every time a key expires) +# e Evicted events (events generated when a key is evicted for maxmemory) +# A Alias for g$lshzxe, so that the "AKE" string means all the events. +# +# The "notify-keyspace-events" takes as argument a string that is composed +# of zero or multiple characters. The empty string means that notifications +# are disabled. +# +# Example: to enable list and generic events, from the point of view of the +# event name, use: +# +# notify-keyspace-events Elg +# +# Example 2: to get the stream of the expired keys subscribing to channel +# name __keyevent@0__:expired use: +# +# notify-keyspace-events Ex +# +# By default all notifications are disabled because most users don't need +# this feature and the feature has some overhead. Note that if you don't +# specify at least one of K or E, no events will be delivered. +notify-keyspace-events "" + +############################### ADVANCED CONFIG ############################### + +# Hashes are encoded using a memory efficient data structure when they have a +# small number of entries, and the biggest entry does not exceed a given +# threshold. These thresholds can be configured using the following directives. +hash-max-ziplist-entries 512 +hash-max-ziplist-value 64 + +# Lists are also encoded in a special way to save a lot of space. +# The number of entries allowed per internal list node can be specified +# as a fixed maximum size or a maximum number of elements. +# For a fixed maximum size, use -5 through -1, meaning: +# -5: max size: 64 Kb <-- not recommended for normal workloads +# -4: max size: 32 Kb <-- not recommended +# -3: max size: 16 Kb <-- probably not recommended +# -2: max size: 8 Kb <-- good +# -1: max size: 4 Kb <-- good +# Positive numbers mean store up to _exactly_ that number of elements +# per list node. +# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size), +# but if your use case is unique, adjust the settings as necessary. +list-max-ziplist-size -2 + +# Lists may also be compressed. +# Compress depth is the number of quicklist ziplist nodes from *each* side of +# the list to *exclude* from compression. The head and tail of the list +# are always uncompressed for fast push/pop operations. Settings are: +# 0: disable all list compression +# 1: depth 1 means "don't start compressing until after 1 node into the list, +# going from either the head or tail" +# So: [head]->node->node->...->node->[tail] +# [head], [tail] will always be uncompressed; inner nodes will compress. +# 2: [head]->[next]->node->node->...->node->[prev]->[tail] +# 2 here means: don't compress head or head->next or tail->prev or tail, +# but compress all nodes between them. +# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail] +# etc. +list-compress-depth 0 + +# Sets have a special encoding in just one case: when a set is composed +# of just strings that happen to be integers in radix 10 in the range +# of 64 bit signed integers. +# The following configuration setting sets the limit in the size of the +# set in order to use this special memory saving encoding. +set-max-intset-entries 512 + +# Similarly to hashes and lists, sorted sets are also specially encoded in +# order to save a lot of space. This encoding is only used when the length and +# elements of a sorted set are below the following limits: +zset-max-ziplist-entries 128 +zset-max-ziplist-value 64 + +# HyperLogLog sparse representation bytes limit. The limit includes the +# 16 bytes header. When an HyperLogLog using the sparse representation crosses +# this limit, it is converted into the dense representation. +# +# A value greater than 16000 is totally useless, since at that point the +# dense representation is more memory efficient. +# +# The suggested value is ~ 3000 in order to have the benefits of +# the space efficient encoding without slowing down too much PFADD, +# which is O(N) with the sparse encoding. The value can be raised to +# ~ 10000 when CPU is not a concern, but space is, and the data set is +# composed of many HyperLogLogs with cardinality in the 0 - 15000 range. +hll-sparse-max-bytes 3000 + +# Streams macro node max size / items. The stream data structure is a radix +# tree of big nodes that encode multiple items inside. Using this configuration +# it is possible to configure how big a single node can be in bytes, and the +# maximum number of items it may contain before switching to a new node when +# appending new stream entries. If any of the following settings are set to +# zero, the limit is ignored, so for instance it is possible to set just a +# max entires limit by setting max-bytes to 0 and max-entries to the desired +# value. +stream-node-max-bytes 4096 +stream-node-max-entries 100 + +# Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in +# order to help rehashing the main Redis hash table (the one mapping top-level +# keys to values). The hash table implementation Redis uses (see dict.c) +# performs a lazy rehashing: the more operation you run into a hash table +# that is rehashing, the more rehashing "steps" are performed, so if the +# server is idle the rehashing is never complete and some more memory is used +# by the hash table. +# +# The default is to use this millisecond 10 times every second in order to +# actively rehash the main dictionaries, freeing memory when possible. +# +# If unsure: +# use "activerehashing no" if you have hard latency requirements and it is +# not a good thing in your environment that Redis can reply from time to time +# to queries with 2 milliseconds delay. +# +# use "activerehashing yes" if you don't have such hard requirements but +# want to free memory asap when possible. +activerehashing yes + +# The client output buffer limits can be used to force disconnection of clients +# that are not reading data from the server fast enough for some reason (a +# common reason is that a Pub/Sub client can't consume messages as fast as the +# publisher can produce them). +# +# The limit can be set differently for the three different classes of clients: +# +# normal -> normal clients including MONITOR clients +# replica -> replica clients +# pubsub -> clients subscribed to at least one pubsub channel or pattern +# +# The syntax of every client-output-buffer-limit directive is the following: +# +# client-output-buffer-limit +# +# A client is immediately disconnected once the hard limit is reached, or if +# the soft limit is reached and remains reached for the specified number of +# seconds (continuously). +# So for instance if the hard limit is 32 megabytes and the soft limit is +# 16 megabytes / 10 seconds, the client will get disconnected immediately +# if the size of the output buffers reach 32 megabytes, but will also get +# disconnected if the client reaches 16 megabytes and continuously overcomes +# the limit for 10 seconds. +# +# By default normal clients are not limited because they don't receive data +# without asking (in a push way), but just after a request, so only +# asynchronous clients may create a scenario where data is requested faster +# than it can read. +# +# Instead there is a default limit for pubsub and replica clients, since +# subscribers and replicas receive data in a push fashion. +# +# Both the hard or the soft limit can be disabled by setting them to zero. +client-output-buffer-limit normal 0 0 0 +client-output-buffer-limit replica 256mb 64mb 60 +client-output-buffer-limit pubsub 32mb 8mb 60 + +# Client query buffers accumulate new commands. They are limited to a fixed +# amount by default in order to avoid that a protocol desynchronization (for +# instance due to a bug in the client) will lead to unbound memory usage in +# the query buffer. However you can configure it here if you have very special +# needs, such us huge multi/exec requests or alike. +# +# client-query-buffer-limit 1gb + +# In the Redis protocol, bulk requests, that are, elements representing single +# strings, are normally limited ot 512 mb. However you can change this limit +# here. +# +# proto-max-bulk-len 512mb + +# Redis calls an internal function to perform many background tasks, like +# closing connections of clients in timeout, purging expired keys that are +# never requested, and so forth. +# +# Not all tasks are performed with the same frequency, but Redis checks for +# tasks to perform according to the specified "hz" value. +# +# By default "hz" is set to 10. Raising the value will use more CPU when +# Redis is idle, but at the same time will make Redis more responsive when +# there are many keys expiring at the same time, and timeouts may be +# handled with more precision. +# +# The range is between 1 and 500, however a value over 100 is usually not +# a good idea. Most users should use the default of 10 and raise this up to +# 100 only in environments where very low latency is required. +hz 10 + +# Normally it is useful to have an HZ value which is proportional to the +# number of clients connected. This is useful in order, for instance, to +# avoid too many clients are processed for each background task invocation +# in order to avoid latency spikes. +# +# Since the default HZ value by default is conservatively set to 10, Redis +# offers, and enables by default, the ability to use an adaptive HZ value +# which will temporary raise when there are many connected clients. +# +# When dynamic HZ is enabled, the actual configured HZ will be used as +# as a baseline, but multiples of the configured HZ value will be actually +# used as needed once more clients are connected. In this way an idle +# instance will use very little CPU time while a busy instance will be +# more responsive. +dynamic-hz yes + +# When a child rewrites the AOF file, if the following option is enabled +# the file will be fsync-ed every 32 MB of data generated. This is useful +# in order to commit the file to the disk more incrementally and avoid +# big latency spikes. +aof-rewrite-incremental-fsync yes + +# When redis saves RDB file, if the following option is enabled +# the file will be fsync-ed every 32 MB of data generated. This is useful +# in order to commit the file to the disk more incrementally and avoid +# big latency spikes. +rdb-save-incremental-fsync yes + +# Redis LFU eviction (see maxmemory setting) can be tuned. However it is a good +# idea to start with the default settings and only change them after investigating +# how to improve the performances and how the keys LFU change over time, which +# is possible to inspect via the OBJECT FREQ command. +# +# There are two tunable parameters in the Redis LFU implementation: the +# counter logarithm factor and the counter decay time. It is important to +# understand what the two parameters mean before changing them. +# +# The LFU counter is just 8 bits per key, it's maximum value is 255, so Redis +# uses a probabilistic increment with logarithmic behavior. Given the value +# of the old counter, when a key is accessed, the counter is incremented in +# this way: +# +# 1. A random number R between 0 and 1 is extracted. +# 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1). +# 3. The counter is incremented only if R < P. +# +# The default lfu-log-factor is 10. This is a table of how the frequency +# counter changes with a different number of accesses with different +# logarithmic factors: +# +# +--------+------------+------------+------------+------------+------------+ +# | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits | +# +--------+------------+------------+------------+------------+------------+ +# | 0 | 104 | 255 | 255 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 1 | 18 | 49 | 255 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 10 | 10 | 18 | 142 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 100 | 8 | 11 | 49 | 143 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# +# NOTE: The above table was obtained by running the following commands: +# +# redis-benchmark -n 1000000 incr foo +# redis-cli object freq foo +# +# NOTE 2: The counter initial value is 5 in order to give new objects a chance +# to accumulate hits. +# +# The counter decay time is the time, in minutes, that must elapse in order +# for the key counter to be divided by two (or decremented if it has a value +# less <= 10). +# +# The default value for the lfu-decay-time is 1. A Special value of 0 means to +# decay the counter every time it happens to be scanned. +# +# lfu-log-factor 10 +# lfu-decay-time 1 + +########################### ACTIVE DEFRAGMENTATION ####################### +# +# WARNING THIS FEATURE IS EXPERIMENTAL. However it was stress tested +# even in production and manually tested by multiple engineers for some +# time. +# +# What is active defragmentation? +# ------------------------------- +# +# Active (online) defragmentation allows a Redis server to compact the +# spaces left between small allocations and deallocations of data in memory, +# thus allowing to reclaim back memory. +# +# Fragmentation is a natural process that happens with every allocator (but +# less so with Jemalloc, fortunately) and certain workloads. Normally a server +# restart is needed in order to lower the fragmentation, or at least to flush +# away all the data and create it again. However thanks to this feature +# implemented by Oran Agra for Redis 4.0 this process can happen at runtime +# in an "hot" way, while the server is running. +# +# Basically when the fragmentation is over a certain level (see the +# configuration options below) Redis will start to create new copies of the +# values in contiguous memory regions by exploiting certain specific Jemalloc +# features (in order to understand if an allocation is causing fragmentation +# and to allocate it in a better place), and at the same time, will release the +# old copies of the data. This process, repeated incrementally for all the keys +# will cause the fragmentation to drop back to normal values. +# +# Important things to understand: +# +# 1. This feature is disabled by default, and only works if you compiled Redis +# to use the copy of Jemalloc we ship with the source code of Redis. +# This is the default with Linux builds. +# +# 2. You never need to enable this feature if you don't have fragmentation +# issues. +# +# 3. Once you experience fragmentation, you can enable this feature when +# needed with the command "CONFIG SET activedefrag yes". +# +# The configuration parameters are able to fine tune the behavior of the +# defragmentation process. If you are not sure about what they mean it is +# a good idea to leave the defaults untouched. + +# Enabled active defragmentation +# activedefrag yes + +# Minimum amount of fragmentation waste to start active defrag +# active-defrag-ignore-bytes 100mb + +# Minimum percentage of fragmentation to start active defrag +# active-defrag-threshold-lower 10 + +# Maximum percentage of fragmentation at which we use maximum effort +# active-defrag-threshold-upper 100 + +# Minimal effort for defrag in CPU percentage +# active-defrag-cycle-min 5 + +# Maximal effort for defrag in CPU percentage +# active-defrag-cycle-max 75 + +# Maximum number of set/hash/zset/list fields that will be processed from +# the main dictionary scan +# active-defrag-max-scan-fields 1000 diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/LoggerDelegate.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/LoggerDelegate.kt new file mode 100644 index 000000000..d426374f9 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/LoggerDelegate.kt @@ -0,0 +1,8 @@ +package org.ostelco.diameter.ha + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +fun R.logger(): Lazy { + return lazy { LoggerFactory.getLogger(this.javaClass) } +} diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/client/ClientCCASessionDataReplicatedImpl.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/client/ClientCCASessionDataReplicatedImpl.kt new file mode 100644 index 000000000..d46931b78 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/client/ClientCCASessionDataReplicatedImpl.kt @@ -0,0 +1,188 @@ +package org.ostelco.diameter.ha.client + +import org.jdiameter.api.AvpDataException +import org.jdiameter.api.Request +import org.jdiameter.api.acc.ClientAccSession +import org.jdiameter.client.api.IContainer +import org.jdiameter.client.api.IMessage +import org.jdiameter.client.api.parser.IMessageParser +import org.jdiameter.client.api.parser.ParseException +import org.jdiameter.client.impl.app.cca.IClientCCASessionData +import org.jdiameter.common.api.app.cca.ClientCCASessionState +import org.ostelco.diameter.ha.common.AppSessionDataReplicatedImpl +import org.ostelco.diameter.ha.common.ReplicatedStorage +import org.ostelco.diameter.ha.logger +import java.io.IOException +import java.io.Serializable + + +class ClientCCASessionDataReplicatedImpl(id: String, replicatedStorage: ReplicatedStorage, container: IContainer) : AppSessionDataReplicatedImpl(id, replicatedStorage), IClientCCASessionData { + + private val logger by logger() + + // TODO: Replace this list of constants with an enumeration + private val EVENT_BASED = "EVENT_BASED" + private val REQUEST_TYPE = "REQUEST_TYPE" + private val STATE = "STATE" + private val TXTIMER_ID = "TXTIMER_ID" + private val TXTIMER_REQUEST = "TXTIMER_REQUEST" + private val BUFFER = "BUFFER" + private val GRA = "GRA" + private val GDDFH = "GDDFH" + private val GCCFH = "GCCFH" + + private val messageParser: IMessageParser + + init { + if (!replicatedStorage.exist(id)) { + setAppSessionIface(ClientAccSession::class.java) + } + messageParser = container.assemblerFacility.getComponentInstance(IMessageParser::class.java) + } + + + override fun isEventBased(): Boolean { + return toPrimitive(this.replicatedStorage.getValue(id, EVENT_BASED), true) + } + + override fun setEventBased(b: Boolean) { + storeValue(EVENT_BASED, b.toString()) + } + + override fun isRequestTypeSet(): Boolean { + return toPrimitive(getValue(REQUEST_TYPE), false) + } + + override fun setRequestTypeSet(b: Boolean) { + storeValue(REQUEST_TYPE, b.toString()) + } + + override fun getClientCCASessionState(): ClientCCASessionState { + val value = getValue(STATE) + if (value != null) { + return ClientCCASessionState.valueOf(value) + } else { + throw IllegalStateException() + } + } + + override fun setClientCCASessionState(state: ClientCCASessionState?) { + if (state != null) { + storeValue(STATE, state.toString()) + } + } + + override fun getTxTimerId(): Serializable { + val value = getValue(TXTIMER_ID) + if (value != null) { + return value + } else { + throw IllegalStateException() + } + } + + override fun setTxTimerId(txTimerId: Serializable?) { + if (txTimerId != null) { + storeValue(TXTIMER_ID, txTimerId.toString()) + } + } + + override fun getTxTimerRequest(): Request? { + val b64String = getValue(TXTIMER_REQUEST) + if (b64String != null) { + try { + return this.messageParser.createMessage(byteArrayFromBase64String(b64String)) + } catch (e: IOException) { + logger.error("Failed to decode Tx Timer Request", e) + } catch (e: ClassNotFoundException) { + logger.error("Failed to decode Tx Timer Request", e) + } catch (e: AvpDataException) { + logger.error("Failed to decode Tx Timer Request", e) + } + } + return null + } + + override fun setTxTimerRequest(txTimerRequest: Request?) { + if (txTimerRequest != null) { + try { + val data = this.messageParser.encodeMessage(txTimerRequest as IMessage) + storeValue(byteBufferToBase64String(data), TXTIMER_REQUEST) + } catch (e: IOException) { + logger.error("Unable to encode Tx Timer Request to buffer.", e) + } + } else { + this.replicatedStorage.removeValue(id, TXTIMER_REQUEST) + } + } + + override fun getBuffer(): Request? { + val b64String = getValue(BUFFER) + if (b64String != null) { + try { + return this.messageParser.createMessage(byteArrayFromBase64String(b64String)) + } catch (e : IOException) { + logger.error("Unable to recreate message from buffer.", e) + } catch (e : ClassNotFoundException) { + logger.error("Unable to recreate message from buffer.", e) + } catch (e: AvpDataException) { + logger.error("Unable to recreate message from buffer.", e) + } + } + return null + } + + override fun setBuffer(buffer: Request?) { + if (buffer != null) { + try { + val data = this.messageParser.encodeMessage(buffer as IMessage) + storeValue(byteBufferToBase64String(data), BUFFER) + } catch (e: ParseException) { + logger.error("Unable to encode message to buffer.", e) + } + + } else { + this.replicatedStorage.removeValue(id, BUFFER) + } + } + + override fun getGatheredRequestedAction(): Int { + val value = getValue(GRA) + if (value != null) { + return value.toInt() + } else { + throw java.lang.IllegalStateException() + } + } + + override fun setGatheredRequestedAction(gatheredRequestedAction: Int) { + storeValue(GRA, gatheredRequestedAction.toString()) + } + + override fun getGatheredCCFH(): Int { + val value = getValue(GCCFH) + if (value != null) { + return value.toInt() + } else { + throw IllegalStateException() + } + } + + override fun setGatheredCCFH(gatheredCCFH: Int) { + storeValue(GCCFH, gatheredCCFH.toString()) + } + + override fun getGatheredDDFH(): Int { + val value = getValue(GDDFH) + if (value != null) { + return value.toInt() + } else { + throw IllegalStateException() + } + } + + override fun setGatheredDDFH(gatheredDDFH: Int) { + storeValue(GDDFH, gatheredDDFH.toString()) + } + +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/AppSessionDataReplicatedImpl.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/AppSessionDataReplicatedImpl.kt new file mode 100644 index 000000000..daeba78c4 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/AppSessionDataReplicatedImpl.kt @@ -0,0 +1,145 @@ +package org.ostelco.diameter.ha.common + +import org.jdiameter.api.ApplicationId +import org.jdiameter.api.acc.ClientAccSession +import org.jdiameter.common.api.app.IAppSessionData +import org.ostelco.diameter.ha.logger +import java.io.* +import java.lang.IllegalStateException +import java.nio.ByteBuffer +import java.util.* +import org.jdiameter.api.app.AppSession + + + +open class AppSessionDataReplicatedImpl(val id: String, val replicatedStorage: ReplicatedStorage) : IAppSessionData { + + private val logger by logger() + + private val APID = "APID" + + fun setAppSessionIface(iface: Class) { + storeValue(SIFACE, toBase64String(iface)) + } + + /** + * Returns the session-id of the session to which this data belongs to. + * @return a string representing the session-id + */ + override fun getSessionId(): String { + return id + } + + /** + * Sets the Application-Id of this Session Data session to which this data belongs to. + * @param applicationId the Application-Id + */ + override fun setApplicationId(applicationId: ApplicationId?) { + if (applicationId != null) { + storeValue(APID, toBase64String(applicationId)) + } + } + + /** + * Returns the Application-Id of this Session Data session to which this data belongs to. + * + * @return the Application-Id + */ + override fun getApplicationId(): ApplicationId { + val value = getValue(APID) + if (value != null) { + return fromBase64String(value) as ApplicationId + } else { + throw IllegalStateException() + } + } + + /** + * Removes this session data from storage + * + * @return true if removed, false otherwise + */ + override fun remove(): Boolean { + var removed = false + if (replicatedStorage.exist(id)) { + logger.debug("Removing id : $id") + replicatedStorage.removeId(id) + removed = true + } + return removed + } + + protected fun toPrimitive(boolString: String?, default: Boolean): Boolean { + if (boolString != null) { + return boolString.toBoolean() + } + return default + } + + protected fun storeValue(key: String, value: String) : Boolean { + logger.debug("Storing key : $key , value : $value , id : $id") + return this.replicatedStorage.storeValue(id, key, value) + } + + protected fun getValue(key: String) : String? { + val value = this.replicatedStorage.getValue(id, key) + logger.debug("Got key : $key , value : $value , id : $id") + return value + } + + /** + * Convert ByteBuffer to a Base64 encoded string + */ + @Throws(IOException::class) + protected fun byteBufferToBase64String(data: ByteBuffer): String { + val array = ByteArray(data.remaining()) + data.get(array) + return Base64.getEncoder().encodeToString(array) + } + + /** + * Read the object from Base64 string. + **/ + @Throws(IOException::class, ClassNotFoundException::class) + protected fun byteArrayFromBase64String(b64String: String): ByteArray? { + return Base64.getDecoder().decode(b64String) + } + + companion object AppSessionHelper { + + private val SIFACE = "SIFACE" + + fun getAppSessionIface(storage: ReplicatedStorage, sessionId: String): Class { + val value = storage.getValue(sessionId, SIFACE) + if (value != null) { + return fromBase64String(value) as Class + } + return ClientAccSession::class.java + } + + /** + * Convert Serializable to a Base64 encoded string + */ + @Throws(IOException::class) + fun toBase64String(serializable: Serializable?): String { + val byteArrayOutputStream = ByteArrayOutputStream() + val objectOutputStream = ObjectOutputStream(byteArrayOutputStream) + objectOutputStream.writeObject(serializable) + objectOutputStream.close() + return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()) + } + + /** + * Read the object from Base64 encoded string. + **/ + @Throws(IOException::class, ClassNotFoundException::class) + fun fromBase64String(b64String: String): Serializable { + val data = Base64.getDecoder().decode(b64String) + val objectInputStream = ObjectInputStream(ByteArrayInputStream(data)) + val any = objectInputStream.readObject() as Serializable + objectInputStream.close() + return any + } + + } +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/RedisStorage.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/RedisStorage.kt new file mode 100644 index 000000000..490a2a00b --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/RedisStorage.kt @@ -0,0 +1,70 @@ +package org.ostelco.diameter.ha.common + +import io.lettuce.core.RedisClient +import io.lettuce.core.RedisURI +import io.lettuce.core.api.StatefulRedisConnection +import io.lettuce.core.api.async.RedisAsyncCommands +import java.util.concurrent.TimeUnit + + +class RedisStorage : ReplicatedStorage { + + private val redisURI = RedisURI.Builder.redis(getRedisHostName(), getRedisPort()).build() + private val redisClient : RedisClient = RedisClient.create(redisURI) + private lateinit var connection : StatefulRedisConnection + private lateinit var asyncCommands: RedisAsyncCommands + + + override fun start() { + connection = redisClient.connect() + asyncCommands = connection.async() + } + + override fun storeValue(id: String, key: String, value: String) : Boolean { + asyncCommands.hset(id, key, value) + // Keys will be auto deleted from Redis if not updated within 3 days + asyncCommands.expire(id, 259200) + return true + } + + override fun getValue(id:String, key: String): String? { + return asyncCommands.hget(id,key).get(5, TimeUnit.SECONDS) + } + + override fun removeValue(id:String, key: String) { + asyncCommands.hdel(id, key) + } + + override fun removeId(id: String) { + val keys = asyncCommands.hkeys(id).get(5, TimeUnit.SECONDS) + keys.forEach { key -> + removeValue(id, key) + } + } + + override fun exist(id: String) : Boolean { + return (asyncCommands.hlen(id).get(5, TimeUnit.SECONDS) > 0) + } + + override fun stop() { + connection.close() + redisClient.shutdown() + } + + private fun getRedisHostName() : String { + var hostname = System.getenv("REDIS_HOSTNAME") + if (hostname == null || hostname.isEmpty()) { + hostname = "localhost" + } + return hostname + } + + private fun getRedisPort() : Int { + val portEnv = System.getenv("REDIS_PORT") + var port = 6379 + if (portEnv != null) { + port = portEnv.toInt() + } + return port + } +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/ReplicatedStorage.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/ReplicatedStorage.kt new file mode 100644 index 000000000..e088e5237 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/common/ReplicatedStorage.kt @@ -0,0 +1,63 @@ +package org.ostelco.diameter.ha.common + +interface ReplicatedStorage { + + /** + * Initialize the storage + */ + fun start() + + /** + * Shutdown storage + */ + fun stop() + + + /** + * Store a key value pair in + * + * @param id the sessionId for the value to will store + * @param key + * @param value + * @return Boolean integer-reply specifically: + * + * {@literal true} if {@code key} is a new key and {@code value} was set. {@literal false} if + * {@code key} already exists for the {@code id} and the value was updated. + */ + fun storeValue(id: String, key: String, value: String) : Boolean + + + /** + * Get a key value pair for an id + * + * @param id the sessionId for the value to retrieve + * @param key + * @return String with the value associated for the key and id, or null if not present + */ + fun getValue(id:String, key: String): String? + + + /** + * Remove a key value pair for an id + * + * @param id the sessionId for the value to remove + * @param key + */ + fun removeValue(id:String, key: String) + + + /** + * Remove all key value pairs for an id + * + * @param id the sessionId for the value to remove + */ + fun removeId(id: String) + + + /** + * Check if session id has been stored + * + * @param id the sessionId + */ + fun exist(id: String) : Boolean +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/server/ServerCCASessionDataReplicatedImpl.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/server/ServerCCASessionDataReplicatedImpl.kt new file mode 100644 index 000000000..860bc9b89 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/server/ServerCCASessionDataReplicatedImpl.kt @@ -0,0 +1,59 @@ +package org.ostelco.diameter.ha.server + +import org.jdiameter.api.cca.ServerCCASession +import org.jdiameter.common.api.app.cca.ServerCCASessionState +import org.jdiameter.server.impl.app.cca.IServerCCASessionData +import org.ostelco.diameter.ha.common.AppSessionDataReplicatedImpl +import org.ostelco.diameter.ha.common.ReplicatedStorage +import java.io.* +import java.lang.IllegalStateException + +class ServerCCASessionDataReplicatedImpl(sessionId: String, replicatedStorage: ReplicatedStorage) : AppSessionDataReplicatedImpl(sessionId, replicatedStorage), IServerCCASessionData { + + private val TCCID = "TCCID" + private val STATELESS = "STATELESS" + private val STATE = "STATE" + + init { + if (!replicatedStorage.exist(sessionId)) { + setAppSessionIface(ServerCCASession::class.java) + serverCCASessionState = ServerCCASessionState.IDLE + } + } + + override fun isStateless(): Boolean { + return toPrimitive(getValue(STATELESS), true) + } + + override fun setStateless(stateless: Boolean) { + storeValue(STATELESS, stateless.toString()) + } + + override fun getServerCCASessionState(): ServerCCASessionState { + val value = getValue(STATE) + if (value != null) { + return ServerCCASessionState.valueOf(value) + } else { + throw IllegalStateException() + } + } + + override fun setServerCCASessionState(state: ServerCCASessionState?) { + storeValue(STATE, state.toString()) + } + + override fun setTccTimerId(tccTimerId: Serializable?) { + if (tccTimerId != null) { + storeValue(TCCID, toBase64String(tccTimerId)) + } + } + + override fun getTccTimerId(): Serializable? { + val value = getValue(TCCID) + if (value != null) { + return fromBase64String(value) + } else { + return value + } + } +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatafactory/CCAReplicatedSessionDataFactory.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatafactory/CCAReplicatedSessionDataFactory.kt new file mode 100644 index 000000000..760f5c069 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatafactory/CCAReplicatedSessionDataFactory.kt @@ -0,0 +1,29 @@ +package org.ostelco.diameter.ha.sessiondatafactory + +import org.jdiameter.api.app.AppSession +import org.jdiameter.api.cca.ClientCCASession +import org.jdiameter.api.cca.ServerCCASession +import org.jdiameter.common.api.app.IAppSessionDataFactory +import org.jdiameter.common.api.app.cca.ICCASessionData +import org.jdiameter.common.api.data.ISessionDatasource +import org.ostelco.diameter.ha.client.ClientCCASessionDataReplicatedImpl +import org.ostelco.diameter.ha.common.ReplicatedStorage +import org.ostelco.diameter.ha.server.ServerCCASessionDataReplicatedImpl +import org.ostelco.diameter.ha.sessiondatasource.RedisReplicatedSessionDatasource + +class CCAReplicatedSessionDataFactory(replicatedSessionDataSource: ISessionDatasource, private val replicatedStorage: ReplicatedStorage) : IAppSessionDataFactory { + + private val replicatedSessionDataSource: RedisReplicatedSessionDatasource = replicatedSessionDataSource as RedisReplicatedSessionDatasource + + override fun getAppSessionData(clazz: Class, sessionId: String): ICCASessionData { + + if (clazz == ClientCCASession::class.java) { + val data = ClientCCASessionDataReplicatedImpl(sessionId, replicatedStorage, replicatedSessionDataSource.container) + return data + } else if (clazz == ServerCCASession::class.java) { + val data = ServerCCASessionDataReplicatedImpl(sessionId, replicatedStorage) + return data + } + throw IllegalArgumentException(clazz.toString()) + } +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatasource/LocalSessionDatasource.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatasource/LocalSessionDatasource.kt new file mode 100644 index 000000000..0936c5a94 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatasource/LocalSessionDatasource.kt @@ -0,0 +1,129 @@ +package org.ostelco.diameter.ha.sessiondatasource + +import org.jdiameter.api.BaseSession +import org.jdiameter.api.NetworkReqListener +import org.jdiameter.client.api.IContainer +import org.jdiameter.common.api.app.IAppSessionData +import org.jdiameter.common.api.app.IAppSessionDataFactory +import org.jdiameter.common.api.app.acc.IAccSessionData +import org.jdiameter.common.api.app.auth.IAuthSessionData +import org.jdiameter.common.api.app.cca.ICCASessionData +import org.jdiameter.common.api.app.cxdx.ICxDxSessionData +import org.jdiameter.common.api.app.gx.IGxSessionData +import org.jdiameter.common.api.app.rf.IRfSessionData +import org.jdiameter.common.api.app.ro.IRoSessionData +import org.jdiameter.common.api.app.rx.IRxSessionData +import org.jdiameter.common.api.app.s13.IS13SessionData +import org.jdiameter.common.api.app.sh.IShSessionData +import org.jdiameter.common.api.app.slg.ISLgSessionData +import org.jdiameter.common.api.app.slh.ISLhSessionData +import org.jdiameter.common.api.data.ISessionDatasource +import org.jdiameter.common.impl.app.acc.AccLocalSessionDataFactory +import org.jdiameter.common.impl.app.auth.AuthLocalSessionDataFactory +import org.jdiameter.common.impl.app.cca.CCALocalSessionDataFactory +import org.jdiameter.common.impl.app.cxdx.CxDxLocalSessionDataFactory +import org.jdiameter.common.impl.app.gx.GxLocalSessionDataFactory +import org.jdiameter.common.impl.app.rf.RfLocalSessionDataFactory +import org.jdiameter.common.impl.app.ro.RoLocalSessionDataFactory +import org.jdiameter.common.impl.app.rx.RxLocalSessionDataFactory +import org.jdiameter.common.impl.app.s13.S13LocalSessionDataFactory +import org.jdiameter.common.impl.app.sh.ShLocalSessionDataFactory +import org.jdiameter.common.impl.app.slg.SLgLocalSessionDataFactory +import org.jdiameter.common.impl.app.slh.SLhLocalSessionDataFactory +import org.jdiameter.common.impl.data.LocalDataSource +import org.ostelco.diameter.ha.logger +import java.util.HashMap + +/** + * Test class that only use localDataSource + */ +class LocalSessionDatasource(container: IContainer) : ISessionDatasource { + + private val logger by logger() + private val localDataSource: ISessionDatasource = LocalDataSource() + + protected var appSessionDataFactories = HashMap, IAppSessionDataFactory>() + + init { + appSessionDataFactories[IAuthSessionData::class.java] = AuthLocalSessionDataFactory() + appSessionDataFactories[IAccSessionData::class.java] = AccLocalSessionDataFactory() + appSessionDataFactories[ICCASessionData::class.java] = CCALocalSessionDataFactory() + appSessionDataFactories[IRoSessionData::class.java] = RoLocalSessionDataFactory() + appSessionDataFactories[IRfSessionData::class.java] = RfLocalSessionDataFactory() + appSessionDataFactories[IShSessionData::class.java] = ShLocalSessionDataFactory() + appSessionDataFactories[ICxDxSessionData::class.java] = CxDxLocalSessionDataFactory() + appSessionDataFactories[IGxSessionData::class.java] = GxLocalSessionDataFactory() + appSessionDataFactories[IRxSessionData::class.java] = RxLocalSessionDataFactory() + appSessionDataFactories[IS13SessionData::class.java] = S13LocalSessionDataFactory() + appSessionDataFactories[ISLhSessionData::class.java] = SLhLocalSessionDataFactory() + appSessionDataFactories[ISLgSessionData::class.java] = SLgLocalSessionDataFactory() + } + + override fun isClustered(): Boolean { + return false + } + + override fun start() { + logger.debug("start") + } + + override fun stop() { + logger.debug("stop") + } + + override fun setSessionListener(sessionId: String?, data: NetworkReqListener?) { + if (localDataSource.exists(sessionId)) { + localDataSource.setSessionListener(sessionId, data) + } else { + logger.error("could not find session $sessionId") + } + } + + override fun removeSessionListener(sessionId: String?): NetworkReqListener? { + if (localDataSource.exists(sessionId)) { + return localDataSource.removeSessionListener(sessionId) + } else { + logger.error("could not remove session $sessionId") + } + return null + } + + override fun removeSession(sessionId: String?) { + if (localDataSource.exists(sessionId)) { + localDataSource.removeSession(sessionId) + } else { + logger.error("Session not found $sessionId") + } + } + + override fun getSession(sessionId: String?): BaseSession? { + if (this.localDataSource.exists(sessionId)) { + return this.localDataSource.getSession(sessionId) + } else { + logger.error("Session $sessionId not found") + } + return null + } + + override fun exists(sessionId: String?): Boolean { + return this.localDataSource.exists(sessionId) + } + + override fun getSessionListener(sessionId: String?): NetworkReqListener? { + if (localDataSource.exists(sessionId)) { + return localDataSource.getSessionListener(sessionId) + } else { + logger.error("Could not get session listener for sessionId $sessionId") + } + return null + } + + override fun getDataFactory(x: Class?): IAppSessionDataFactory? { + return this.appSessionDataFactories[x] + } + + override fun addSession(session: BaseSession?) { + this.localDataSource.addSession(session) + } + +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatasource/RedisReplicatedSessionDatasource.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatasource/RedisReplicatedSessionDatasource.kt new file mode 100644 index 000000000..971a51edc --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/sessiondatasource/RedisReplicatedSessionDatasource.kt @@ -0,0 +1,186 @@ +package org.ostelco.diameter.ha.sessiondatasource + +import org.jdiameter.api.BaseSession +import org.jdiameter.api.IllegalDiameterStateException +import org.jdiameter.api.NetworkReqListener +import org.jdiameter.client.api.IContainer +import org.jdiameter.client.api.ISessionFactory +import org.jdiameter.common.api.app.IAppSessionData +import org.jdiameter.common.api.app.IAppSessionDataFactory +import org.jdiameter.common.api.app.acc.IAccSessionData +import org.jdiameter.common.api.app.auth.IAuthSessionData +import org.jdiameter.common.api.app.cca.ICCASessionData +import org.jdiameter.common.api.app.cxdx.ICxDxSessionData +import org.jdiameter.common.api.app.gx.IGxSessionData +import org.jdiameter.common.api.app.rf.IRfSessionData +import org.jdiameter.common.api.app.ro.IRoSessionData +import org.jdiameter.common.api.app.rx.IRxSessionData +import org.jdiameter.common.api.app.s13.IS13SessionData +import org.jdiameter.common.api.app.sh.IShSessionData +import org.jdiameter.common.api.app.slg.ISLgSessionData +import org.jdiameter.common.api.app.slh.ISLhSessionData +import org.jdiameter.common.api.data.ISessionDatasource +import org.jdiameter.common.impl.app.acc.AccLocalSessionDataFactory +import org.jdiameter.common.impl.app.auth.AuthLocalSessionDataFactory +import org.jdiameter.common.impl.app.cxdx.CxDxLocalSessionDataFactory +import org.jdiameter.common.impl.app.gx.GxLocalSessionDataFactory +import org.jdiameter.common.impl.app.rf.RfLocalSessionDataFactory +import org.jdiameter.common.impl.app.ro.RoLocalSessionDataFactory +import org.jdiameter.common.impl.app.rx.RxLocalSessionDataFactory +import org.jdiameter.common.impl.app.s13.S13LocalSessionDataFactory +import org.jdiameter.common.impl.app.sh.ShLocalSessionDataFactory +import org.jdiameter.common.impl.app.slg.SLgLocalSessionDataFactory +import org.jdiameter.common.impl.app.slh.SLhLocalSessionDataFactory +import org.jdiameter.common.impl.data.LocalDataSource +import org.ostelco.diameter.ha.common.AppSessionDataReplicatedImpl +import org.ostelco.diameter.ha.common.RedisStorage +import org.ostelco.diameter.ha.logger +import org.ostelco.diameter.ha.sessiondatafactory.CCAReplicatedSessionDataFactory +import java.util.HashMap + +/** + * A Replicated DataSource that will use Redis as a remote store to save session information. + */ +class RedisReplicatedSessionDatasource(val container: IContainer) : ISessionDatasource { + + private val logger by logger() + private val localDataSource: ISessionDatasource = LocalDataSource() + + private var appSessionDataFactories = HashMap, IAppSessionDataFactory>() + + private val redisStorage = RedisStorage() + + + // We only care about ICCASessionData so that is the only one we have re-implemented right now + init { + appSessionDataFactories[IAuthSessionData::class.java] = AuthLocalSessionDataFactory() + appSessionDataFactories[IAccSessionData::class.java] = AccLocalSessionDataFactory() + appSessionDataFactories[ICCASessionData::class.java] = CCAReplicatedSessionDataFactory(this, redisStorage) + appSessionDataFactories[IRoSessionData::class.java] = RoLocalSessionDataFactory() + appSessionDataFactories[IRfSessionData::class.java] = RfLocalSessionDataFactory() + appSessionDataFactories[IShSessionData::class.java] = ShLocalSessionDataFactory() + appSessionDataFactories[ICxDxSessionData::class.java] = CxDxLocalSessionDataFactory() + appSessionDataFactories[IGxSessionData::class.java] = GxLocalSessionDataFactory() + appSessionDataFactories[IRxSessionData::class.java] = RxLocalSessionDataFactory() + appSessionDataFactories[IS13SessionData::class.java] = S13LocalSessionDataFactory() + appSessionDataFactories[ISLhSessionData::class.java] = SLhLocalSessionDataFactory() + appSessionDataFactories[ISLgSessionData::class.java] = SLgLocalSessionDataFactory() + } + + override fun isClustered(): Boolean { + return false + } + + override fun start() { + logger.debug("start") + redisStorage.start() + } + + override fun stop() { + logger.debug("stop") + redisStorage.stop() + } + + override fun setSessionListener(sessionId: String?, data: NetworkReqListener?) { + if (localDataSource.exists(sessionId)) { + localDataSource.setSessionListener(sessionId, data) + } else { + logger.error("Could not set session listener for non local session $sessionId") + } + } + + override fun removeSessionListener(sessionId: String?): NetworkReqListener? { + if (localDataSource.exists(sessionId)) { + return localDataSource.removeSessionListener(sessionId) + } else { + logger.error("Could not remove SessionListener for session $sessionId") + } + return null + } + + override fun removeSession(sessionId: String?) { + if (sessionId != null) { + if (localDataSource.exists(sessionId)) { + localDataSource.removeSession(sessionId) + } else if (existReplicated(sessionId)) { + redisStorage.removeId(sessionId) + } else { + logger.error("Could not remove session : $sessionId. Not found") + } + } + } + + override fun getSession(sessionId: String?): BaseSession? { + if (sessionId != null) { + when { + localDataSource.exists(sessionId) -> { + logger.debug("Using LocalDataSource for session $sessionId") + return localDataSource.getSession(sessionId) + } + existReplicated(sessionId) -> { + logger.debug("Using replicated session : $sessionId") + makeLocal(sessionId) + return localDataSource.getSession(sessionId) + } + else -> logger.error("Session not local or external $sessionId") + } + } + return null + } + + override fun exists(sessionId: String?): Boolean { + return if (localDataSource.exists(sessionId)) true else existReplicated(sessionId) + } + + override fun getSessionListener(sessionId: String?): NetworkReqListener? { + when { + localDataSource.exists(sessionId) -> return localDataSource.getSessionListener(sessionId) + existReplicated(sessionId) -> { + logger.debug("Getting session listener from replicated external source for sessionId $sessionId") + makeLocal(sessionId) + return localDataSource.getSessionListener(sessionId) + } + else -> logger.info("Could not find existing session listener for sessionId $sessionId") + } + return null + } + + override fun getDataFactory(x: Class?): IAppSessionDataFactory? { + return appSessionDataFactories[x] + } + + override fun addSession(session: BaseSession?) { + localDataSource.addSession(session) + } + + private fun makeLocal(sessionId: String?) { + logger.info("MakeLocal sessionId $sessionId") + if (sessionId != null) { + try { + val appSessionInterfaceClass = AppSessionDataReplicatedImpl.getAppSessionIface(redisStorage, sessionId) + val factory = (container.sessionFactory as ISessionFactory).getAppSessionFactory(appSessionInterfaceClass) + if (factory == null) { + logger.warn("Session with id: $sessionId, is in replicated data source, but no Application Session Factory for: $appSessionInterfaceClass.") + return + } else { + val session = factory.getSession(sessionId, appSessionInterfaceClass) + localDataSource.addSession(session) + localDataSource.setSessionListener(sessionId, session as NetworkReqListener) + return + } + } catch (e: IllegalDiameterStateException) { + logger.error("Failed to obtain factory from stack.", e) + } + } else { + logger.error("No sessionId set") + } + } + + private fun existReplicated(sessionId: String?): Boolean { + var sessionIdExist = false + if (sessionId != null) { + sessionIdExist = redisStorage.exist(sessionId) + } + return sessionIdExist + } +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/CancelTimerTaskRunnable.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/CancelTimerTaskRunnable.kt new file mode 100644 index 000000000..27ca08796 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/CancelTimerTaskRunnable.kt @@ -0,0 +1,26 @@ +package org.ostelco.diameter.ha.timer + +import org.ostelco.diameter.ha.logger + +class CancelTimerTaskRunnable internal constructor(task: ReplicatedTimerTask, + scheduler: ReplicatedTimerTaskScheduler) : TimerTaskRunnable(task, scheduler) { + + private val logger by logger() + + override val type: Type + get() = TimerTaskRunnable.Type.CANCEL + + + override fun run() { + + logger.debug("Cancelling timer task for timer ID ${task.data.taskID}") + + scheduler.getLocalRunningTasksMap().remove(task.data.taskID) + + try { + task.cancel() + } catch (e: Throwable) { + logger.error("Failed to cancel task ${task.data.taskID}", e) + } + } +} diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/LocalTimerFacilityImpl.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/LocalTimerFacilityImpl.kt new file mode 100644 index 000000000..0f50b1b4a --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/LocalTimerFacilityImpl.kt @@ -0,0 +1,136 @@ +package org.ostelco.diameter.ha.timer + +import org.apache.commons.pool.BasePoolableObjectFactory +import org.apache.commons.pool.impl.GenericObjectPool +import org.jdiameter.client.api.IContainer +import org.jdiameter.client.impl.BaseSessionImpl +import org.jdiameter.common.api.concurrent.IConcurrentFactory +import org.jdiameter.common.api.data.ISessionDatasource +import org.jdiameter.common.api.timer.ITimerFacility +import org.jdiameter.common.impl.app.AppSessionImpl +import org.ostelco.diameter.ha.logger +import java.io.* +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit + +// Basically re-implementation of jdiameter/core/jdiameter/impl/src/main/java/org/jdiameter/common/impl/timer/LocalTimerFacilityImpl.java in Kotlin +// to get a grip on the functionallity. + +class LocalTimerFacilityImpl(container: IContainer) : ITimerFacility { + + private val logger by logger() + + private val sessionDataSource: ISessionDatasource = container.assemblerFacility.getComponentInstance(ISessionDatasource::class.java) + private val executor: ScheduledThreadPoolExecutor = container.concurrentFactory.getScheduledExecutorService(IConcurrentFactory.ScheduledExecServices.ApplicationSession.name) as ScheduledThreadPoolExecutor + private val pool = GenericObjectPool(TimerTaskHandleFactory(), 100000, GenericObjectPool.WHEN_EXHAUSTED_GROW, 10, 20000) + + /** + * This should schedule a timer do detect if session has timed out. + */ + override fun schedule(sessionId: String?, timerName: String?, milliseconds: Long): Serializable { + + val id = "$sessionId/$timerName" + logger.debug("Scheduling timer with id: $id timerName: $timerName, milliseconds: $milliseconds") + val timerTaskHandle = borrowTimerTaskHandle() + timerTaskHandle!!.id = id + timerTaskHandle.sessionId = sessionId + timerTaskHandle.timerName = timerName + timerTaskHandle.future = executor.schedule(timerTaskHandle, milliseconds, TimeUnit.MILLISECONDS) + return timerTaskHandle + } + + override fun cancel(timerTaskHandle: Serializable?) { + if (timerTaskHandle != null && timerTaskHandle is TimerTaskHandle) { + if (timerTaskHandle.future != null) { + logger.debug("Cancelling timer with id [${timerTaskHandle.id}] and delay [${timerTaskHandle.future!!.getDelay(TimeUnit.MILLISECONDS)}]") + if (executor.remove(timerTaskHandle.future as Runnable)) { + timerTaskHandle.future!!.cancel(false) + returnTimerTaskHandle(timerTaskHandle) + } + } + } + } + + + private fun returnTimerTaskHandle(timerTaskHandle: TimerTaskHandle) { + try { + pool.returnObject(timerTaskHandle) + } catch (e: Exception) { + logger.warn(e.message,e) + } + } + + private fun borrowTimerTaskHandle(): TimerTaskHandle? { + try { + return pool.borrowObject() as TimerTaskHandle? + } catch (e: Exception) { + logger.error(e.message, e) + } + return null + } + + internal inner class TimerTaskHandleFactory : BasePoolableObjectFactory() { + @Throws(Exception::class) + override fun makeObject(): Any { + return TimerTaskHandle() + } + + @Throws(Exception::class) + override fun passivateObject(obj: Any?) { + val timerTaskHandle = obj as TimerTaskHandle? + timerTaskHandle!!.id = null + timerTaskHandle.sessionId = null + timerTaskHandle.timerName = null + timerTaskHandle.future = null + } + } + + private inner class TimerTaskHandle : Runnable, Externalizable { + // its not really serializable; + var sessionId: String? = null + var timerName: String? = null + var id: String? = null + @Transient + var future: ScheduledFuture<*>? = null + + override fun run() { + try { + val bSession = sessionDataSource.getSession(sessionId) + if (bSession == null) { + logger.error("Base Session is null for sessionId: $sessionId") + return + } else { + try { + if (!bSession.isAppSession) { + val impl = bSession as BaseSessionImpl + impl.onTimer(timerName!!) + } else { + val impl = bSession as AppSessionImpl + impl.onTimer(timerName) + } + } catch (e: Exception) { + logger.error("Caught exception from session object!", e) + } + } + } catch (e: Exception) { + logger.error("Failure executing timer task with id: $id", e) + } finally { + returnTimerTaskHandle(this) + } + } + + @Throws(IOException::class) + override fun writeExternal(out: ObjectOutput) { + logger.error("Local timer should not be serialized (writeExternal)") + throw IOException("Failed to serialize local timer!") + } + + @Throws(IOException::class, ClassNotFoundException::class) + override fun readExternal(`in`: ObjectInput) { + logger.error("Local timer should not be serialized (readExternal)") + throw IOException("Failed to deserialize local timer!") + } + } +} + diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerFacilityImpl.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerFacilityImpl.kt new file mode 100644 index 000000000..cb63f0860 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerFacilityImpl.kt @@ -0,0 +1,45 @@ +package org.ostelco.diameter.ha.timer + +import org.jdiameter.client.api.IContainer +import org.jdiameter.common.api.data.ISessionDatasource +import org.jdiameter.common.api.timer.ITimerFacility +import org.ostelco.diameter.ha.logger +import java.io.Serializable + +class ReplicatedTimerFacilityImpl(container: IContainer) : ITimerFacility { + + private val logger by logger() + private val sessionDataSource: ISessionDatasource = container.assemblerFacility.getComponentInstance(ISessionDatasource::class.java) + private val taskFactory: TimerTaskFactory = TimerTaskFactory() + private val replicatedTimerTaskScheduler: ReplicatedTimerTaskScheduler = ReplicatedTimerTaskScheduler() + + override fun schedule(sessionId: String?, timerName: String?, delay: Long): Serializable { + var taskId = "" + logger.debug("Schedule timer with timerName : $timerName sessionId : $sessionId delay : $delay") + if ((sessionId != null) && (timerName != null)) { + taskId = "$sessionId/$timerName" + + val data = ReplicatedTimerTaskData(taskId, sessionId, timerName,System.currentTimeMillis() + delay, -1) + val timerTask = taskFactory.newTimerTask(data) + replicatedTimerTaskScheduler.schedule(timerTask) + } else { + logger.warn("Can not schedule timer with sessionId $sessionId timerName $timerName") + } + return taskId + } + + override fun cancel(id: Serializable?) { + logger.debug("Cancelling timer with id $id") + if (id != null) { + replicatedTimerTaskScheduler.cancel(id) + } + } + + private inner class TimerTaskFactory { + + fun newTimerTask(data: ReplicatedTimerTaskData): ReplicatedTimerTask { + return ReplicatedTimerTask(data, sessionDataSource) + } + } + +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerTaskData.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerTaskData.kt new file mode 100644 index 000000000..526f2d507 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerTaskData.kt @@ -0,0 +1,94 @@ +package org.ostelco.diameter.ha.timer + +import org.jdiameter.client.impl.BaseSessionImpl +import org.jdiameter.common.api.data.ISessionDatasource +import org.jdiameter.common.impl.app.AppSessionImpl +import org.ostelco.diameter.ha.logger +import java.io.Serializable +import java.util.concurrent.ScheduledFuture + +class ReplicatedTimerTaskData(val taskID: Serializable, + val sessionId: String, + val timerName: String, + var startTime: Long, + var period: Long) : Serializable { + + override fun hashCode(): Int { + return taskID.hashCode() + } + + override fun equals(other: Any?): Boolean { + return if (other != null && other.javaClass == this.javaClass) (other as ReplicatedTimerTaskData).taskID == taskID else false + } + + companion object { + private const val serialVersionUID = 8774218122384404226L + } +} + +class ReplicatedTimerTask(val data: ReplicatedTimerTaskData, private val sessionDataSource: ISessionDatasource) : Runnable { + + private val logger by logger() + + var scheduledFuture: ScheduledFuture<*>? = null + set(scheduledFuture) { + field = scheduledFuture + if (cancel) { + scheduledFuture!!.cancel(false) + } + } + + var scheduler: ReplicatedTimerTaskScheduler? = null + private var autoRemoval = true + @Transient + private var cancel: Boolean = false + + + fun cancel() { + cancel = true + if (scheduledFuture != null) { + scheduledFuture!!.cancel(false) + } + } + + override fun run() { + if (data.period < 0L && autoRemoval) { + logger.debug("Task with id ${data.taskID} is not recurring, so removing it") + removeFromScheduler() + } else { + logger.debug("Task with id ${data.taskID} is recurring, not removing it") + } + + logger.debug("Firing Timer with id ${data.taskID}") + + runTask() + } + + private fun removeFromScheduler() { + scheduler!!.remove(data.taskID) + } + + private fun runTask() { + try { + val bSession = sessionDataSource.getSession(data.sessionId) + if (bSession == null) { + logger.error("Base Session is null for sessionId: ${data.sessionId}") + return + } else { + try { + if (bSession.isAppSession) { + val impl = bSession as BaseSessionImpl + impl.onTimer(data.timerName) + } else { + val impl = bSession as AppSessionImpl + impl.onTimer(data.timerName) + } + } catch (e: Exception) { + logger.error("Caught exception from session object!", e) + } + } + } catch (e: Exception) { + logger.error("Failure executing timer task", e) + } + } +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerTaskScheduler.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerTaskScheduler.kt new file mode 100644 index 000000000..eaaa32ca2 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/ReplicatedTimerTaskScheduler.kt @@ -0,0 +1,43 @@ +package org.ostelco.diameter.ha.timer + +import org.ostelco.diameter.ha.logger +import java.io.Serializable +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledThreadPoolExecutor + +class ReplicatedTimerTaskScheduler { + private val logger by logger() + + private val localRunningTasks: ConcurrentHashMap = ConcurrentHashMap() + private val executor = ScheduledThreadPoolExecutor( 5, Executors.defaultThreadFactory()) + + internal fun getExecutor(): ScheduledThreadPoolExecutor { + return executor + } + + internal fun getLocalRunningTasksMap(): ConcurrentHashMap { + return localRunningTasks + } + + internal fun remove(taskID: Serializable) { + logger.debug("Remove taskID : $taskID") + localRunningTasks.remove(taskID) + } + + fun schedule(task: ReplicatedTimerTask) { + task.scheduler = this + SetTimerTaskRunnable(task, this).run() + } + + fun cancel(taskID: Serializable): ReplicatedTimerTask? { + + logger.debug("Cancelling task with timer id $taskID") + + val task: ReplicatedTimerTask? = localRunningTasks[taskID] + if (task != null) { + CancelTimerTaskRunnable(task, this).run() + } + return task + } +} \ No newline at end of file diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/SetTimerTaskRunnable.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/SetTimerTaskRunnable.kt new file mode 100644 index 000000000..4f7811157 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/SetTimerTaskRunnable.kt @@ -0,0 +1,42 @@ +package org.ostelco.diameter.ha.timer + +import org.ostelco.diameter.ha.logger +import java.util.concurrent.TimeUnit + +class SetTimerTaskRunnable(task: ReplicatedTimerTask, + scheduler: ReplicatedTimerTaskScheduler) : TimerTaskRunnable(task, scheduler) { + private val logger by logger() + + override val type: Type + get() = Type.SET + + override fun run() { + logger.debug("SetTimerTaskRunnable run") + val previousTask = scheduler.getLocalRunningTasksMap().putIfAbsent(task.data.taskID, task) + if (previousTask != null) { + logger.debug("A task with id ${task.data.taskID} has already been added to the local tasks, not rescheduling") + return + } + + val taskData = task.data + var delay = taskData.startTime - System.currentTimeMillis() + if (delay < 0L) { + delay = 0L + } + + try { + if (taskData.period < 0L) { + logger.debug("Scheduling one-shot timer with id ${task.data.taskID} , delay $delay") + + task.scheduledFuture = scheduler.getExecutor().schedule(task, delay, TimeUnit.MILLISECONDS) + } else { + logger.debug("Scheduling periodic timer with id ${task.data.taskID}, delay $delay period ${taskData.period}") + + task.scheduledFuture = scheduler.getExecutor().scheduleWithFixedDelay(task, delay, taskData.period, TimeUnit.MILLISECONDS) + } + } catch (t: Throwable) { + logger.error("Failed to schedule task with id ${taskData.taskID}", t) + scheduler.remove(taskData.taskID) + } + } +} diff --git a/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/TimerTaskRunnable.kt b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/TimerTaskRunnable.kt new file mode 100644 index 000000000..a08f1dac3 --- /dev/null +++ b/diameter-ha/src/main/kotlin/org/ostelco/diameter/ha/timer/TimerTaskRunnable.kt @@ -0,0 +1,11 @@ +package org.ostelco.diameter.ha.timer + +abstract class TimerTaskRunnable(protected val task: ReplicatedTimerTask, + protected val scheduler: ReplicatedTimerTaskScheduler) : Runnable { + + abstract val type: Type + + enum class Type { + SET, CANCEL + } +} diff --git a/diameter-stack/build.gradle b/diameter-stack/build.gradle index 4b2846403..0597b63e2 100644 --- a/diameter-stack/build.gradle +++ b/diameter-stack/build.gradle @@ -1,5 +1,5 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" id "signing" id "maven" @@ -9,7 +9,9 @@ version = "1.0.0-SNAPSHOT" dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + implementation ("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") { + exclude group: "org.jetbrains.kotlin", module:"kotlin-reflect" + } api('org.mobicents.diameter:jdiameter-api:1.7.1-123') { exclude module: 'netty-all' } @@ -23,7 +25,7 @@ dependencies { exclude group: 'org.slf4j', module: 'slf4j-log4j12' exclude group: 'log4j', module: 'log4j' } - implementation 'org.slf4j:log4j-over-slf4j:1.7.25' + implementation "org.slf4j:log4j-over-slf4j:$slf4jVersion" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" testRuntimeOnly 'org.hamcrest:hamcrest-all:1.3' @@ -99,4 +101,4 @@ uploadArchives.repositories.mavenDeployer { } } -apply from: '../jacoco.gradle' \ No newline at end of file +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/diameter-stack/config/dictionary.xml b/diameter-stack/config/dictionary.xml index 6f7c02bc2..5fe4c7900 100644 --- a/diameter-stack/config/dictionary.xml +++ b/diameter-stack/config/dictionary.xml @@ -7636,6 +7636,24 @@ + + + + + + + + + + + + + + + enum code="1" name="UNAUTHENTICATED" /> + + + diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/CreditControlContext.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/CreditControlContext.kt index c95659986..2e5246022 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/CreditControlContext.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/CreditControlContext.kt @@ -4,7 +4,6 @@ import org.jdiameter.api.Avp import org.jdiameter.api.AvpSet import org.jdiameter.api.InternalException import org.jdiameter.api.Request -import org.jdiameter.api.ResultCode import org.jdiameter.api.cca.events.JCreditControlRequest import org.jdiameter.common.impl.app.cca.JCreditControlAnswerImpl import org.ostelco.diameter.model.CreditControlAnswer @@ -25,12 +24,14 @@ import org.ostelco.diameter.util.DiameterUtilities class CreditControlContext( val sessionId: String, val originalCreditControlRequest: JCreditControlRequest, - val originHost: String, - val originRealm: String) { + private val originHost: String, + private val originRealm: String) { - private val logger by logger() + private val logger by getLogger() - // Set to true, when answer to not to be sent to PGw. Default value is false. + private val VENDOR_ID_3GPP = 10415L + + // Set to true, when answer to not to be sent to P-GW. var skipAnswer: Boolean = false val creditControlRequest: CreditControlRequest = AvpParser().parse( @@ -43,10 +44,9 @@ class CreditControlContext( fun createCCA(creditControlAnswer: CreditControlAnswer): JCreditControlAnswerImpl? { var answer: JCreditControlAnswerImpl? = null - val resultCode = ResultCode.SUCCESS try { - answer = JCreditControlAnswerImpl(originalCreditControlRequest.message as Request, ResultCode.SUCCESS.toLong()) + answer = JCreditControlAnswerImpl(originalCreditControlRequest.message as Request, creditControlAnswer.resultCode.value.toLong()) val ccaAvps = answer.message.avps @@ -56,44 +56,53 @@ class CreditControlContext( ccaAvps.addAvp(Avp.ORIGIN_HOST, originHost, true, false, true) ccaAvps.addAvp(Avp.ORIGIN_REALM, originRealm, true, false, true) - val multipleServiceCreditControls = creditControlAnswer.multipleServiceCreditControls + addMultipleServiceCreditControls(ccaAvps, creditControlAnswer) - for (mscc in multipleServiceCreditControls) { + logger.info("Created Credit-Control-Answer") + DiameterUtilities().printAvps(ccaAvps) - val answerMSCC = ccaAvps.addGroupedAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL, true, false) - if (mscc.ratingGroup > 0) { - answerMSCC.addAvp(Avp.RATING_GROUP, mscc.ratingGroup, true, false, true) - } + } catch (e: InternalException) { + logger.error("Failed to convert to Credit-Control-Answer", e) + } - if (mscc.serviceIdentifier > 0) { - // This is a bug in jDiameter due to which this unsigned32 field has to be set as Int and not Long. - answerMSCC.addAvp(Avp.SERVICE_IDENTIFIER_CCA, mscc.serviceIdentifier.toInt(), true, false) - } + return answer + } + + private fun addMultipleServiceCreditControls(ccaAvps: AvpSet, creditControlAnswer: CreditControlAnswer) { + for (mscc in creditControlAnswer.multipleServiceCreditControls) { + + val answerMSCC = ccaAvps.addGroupedAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL, true, false) + if (mscc.ratingGroup > 0) { + answerMSCC.addAvp(Avp.RATING_GROUP, mscc.ratingGroup, true, false, true) + } + + if (mscc.serviceIdentifier > 0) { + // This is a bug in jDiameter due to which this unsigned32 field has to be set as Int and not Long. + answerMSCC.addAvp(Avp.SERVICE_IDENTIFIER_CCA, mscc.serviceIdentifier.toInt(), true, false) + } - if (originalCreditControlRequest.requestTypeAVPValue != RequestType.TERMINATION_REQUEST) { + if (originalCreditControlRequest.requestTypeAVPValue != RequestType.TERMINATION_REQUEST) { - if (mscc.finalUnitIndication != null) { - addFinalUnitAction(answerMSCC, mscc) - } + if (mscc.finalUnitIndication != null) { + addFinalUnitAction(answerMSCC, mscc) + } - if (mscc.granted.total > -1) { - val gsuAvp = answerMSCC.addGroupedAvp(Avp.GRANTED_SERVICE_UNIT, true, false) - gsuAvp.addAvp(Avp.CC_INPUT_OCTETS, 0L, true, false) - gsuAvp.addAvp(Avp.CC_OUTPUT_OCTETS, 0L, true, false) - gsuAvp.addAvp(Avp.CC_TOTAL_OCTETS, mscc.granted.total, true, false) - } + if (mscc.granted.total > -1) { + val gsuAvp = answerMSCC.addGroupedAvp(Avp.GRANTED_SERVICE_UNIT, true, false) + gsuAvp.addAvp(Avp.CC_INPUT_OCTETS, 0L, true, false) + gsuAvp.addAvp(Avp.CC_OUTPUT_OCTETS, 0L, true, false) + gsuAvp.addAvp(Avp.CC_TOTAL_OCTETS, mscc.granted.total, true, false) } - answerMSCC.addAvp(Avp.RESULT_CODE, resultCode, true, false) - answerMSCC.addAvp(Avp.VALIDITY_TIME, mscc.validityTime, true, false) } - logger.info("Credit-Control-Answer") - DiameterUtilities().printAvps(ccaAvps) - } catch (e: InternalException) { - logger.error("Failed to convert to Credit-Control-Answer", e) - } + answerMSCC.addAvp(Avp.RESULT_CODE, mscc.resultCode.value, true, false) + answerMSCC.addAvp(Avp.VALIDITY_TIME, mscc.validityTime, true, false) - return answer + if (mscc.granted.total > 0) { + answerMSCC.addAvp(Avp.QUOTA_HOLDING_TIME, mscc.quotaHoldingTime, VENDOR_ID_3GPP, true, false, true) + answerMSCC.addAvp(Avp.VOLUME_QUOTA_THRESHOLD, mscc.volumeQuotaThreshold, VENDOR_ID_3GPP,true, false, true) + } + } } private fun addFinalUnitAction(answerMSCC: AvpSet, mscc: MultipleServiceCreditControl) { diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/LoggerDelegate.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/LoggerDelegate.kt index d7a339c6b..52e023265 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/LoggerDelegate.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/LoggerDelegate.kt @@ -3,6 +3,6 @@ package org.ostelco.diameter import org.slf4j.Logger import org.slf4j.LoggerFactory -fun R.logger(): Lazy { +fun R.getLogger(): Lazy { return lazy { LoggerFactory.getLogger(this.javaClass) } } diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/builder/Builder.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/builder/Builder.kt index 330e09610..c3ed0e94d 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/builder/Builder.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/builder/Builder.kt @@ -13,7 +13,7 @@ fun set(avpSet: AvpSet, init: AvpSetContext.() -> Unit) { avpSetContext.init() } -class AvpSetContext(val avpSet: AvpSet) { +class AvpSetContext(private val avpSet: AvpSet) { fun avp( avpCode: Int, value: Any, vendorId: Long = 0, mFlag: Boolean = true, pFlag: Boolean = false, asOctetString: Boolean = false) { diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/CreditControlRequest.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/CreditControlRequest.kt index cb2f1cc5e..802d2333b 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/CreditControlRequest.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/CreditControlRequest.kt @@ -1,7 +1,6 @@ package org.ostelco.diameter.model import org.jdiameter.api.Avp -import org.jdiameter.api.AvpSet import org.ostelco.diameter.model.SubscriptionType.END_USER_E164 import org.ostelco.diameter.model.SubscriptionType.END_USER_IMSI import org.ostelco.diameter.parser.AvpField @@ -43,14 +42,4 @@ class CreditControlRequest { @AvpField(Avp.ORIGIN_REALM) var originRealm: String? = "" - - var ccrAvps: AvpSet? = null - - // TODO martin: This should be connected to rating groups - val requestedUnits: Long - get() = this.multipleServiceCreditControls.first().requested[0].total - - // TODO martin: This only get the total. There is also input/output if needed - val usedUnits: Long - get() = this.multipleServiceCreditControls.first().used.total } diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/Model.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/Model.kt index bd54c5a0b..956e54031 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/Model.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/model/Model.kt @@ -25,12 +25,18 @@ object RequestType { /** * Internal representation of the Credit-Control-Answer */ -data class CreditControlAnswer(val multipleServiceCreditControls: List) +data class CreditControlAnswer( + val resultCode: ResultCode, + val multipleServiceCreditControls: List) -enum class CreditControlResultCode(val value: Int) { +enum class ResultCode(val value: Int) { + DIAMETER_SUCCESS(2001), DIAMETER_END_USER_SERVICE_DENIED(4010), DIAMETER_CREDIT_CONTROL_NOT_APPLICABLE(4011), DIAMETER_CREDIT_LIMIT_REACHED(4012), + DIAMETER_INVALID_AVP_VALUE(5004), + DIAMETER_MISSING_AVP(5005), + DIAMETER_UNABLE_TO_COMPLY(5012), DIAMETER_RATING_FAILED(5031), DIAMETER_USER_UNKNOWN(5030) } @@ -108,19 +114,39 @@ class MultipleServiceCreditControl() { @AvpField(Avp.REPORTING_REASON) var reportingReason: ReportingReason? = null + var resultCode: ResultCode = ResultCode.DIAMETER_SUCCESS + var validityTime = 86400 + var quotaHoldingTime = 0L + + var volumeQuotaThreshold = 0L + // https://tools.ietf.org/html/rfc4006#section-8.34 var finalUnitIndication: FinalUnitIndication? = null - constructor(ratingGroup: Long, serviceIdentifier: Long, requested: List, used: ServiceUnit, granted: ServiceUnit, validityTime: Int, finalUnitIndication: FinalUnitIndication?) : this() { + constructor( + ratingGroup: Long, + serviceIdentifier: Long, + requested: List, + used: ServiceUnit, + granted: ServiceUnit, + validityTime: Int, + quotaHoldingTime: Long, + volumeQuotaThreshold: Long, + finalUnitIndication: FinalUnitIndication?, + resultCode: ResultCode) : this() { + this.ratingGroup = ratingGroup this.serviceIdentifier = serviceIdentifier this.requested = requested this.used = used this.granted = granted this.validityTime = validityTime + this.quotaHoldingTime = quotaHoldingTime + this.volumeQuotaThreshold = volumeQuotaThreshold this.finalUnitIndication = finalUnitIndication + this.resultCode = resultCode } } @@ -209,7 +235,7 @@ enum class UserEquipmentInfoType { data class SessionContext( val sessionId: String, - val originHost: String, - val originRealm: String, - val apn: String, - val mccMnc: String) \ No newline at end of file + val originHost: String?, + val originRealm: String?, + val apn: String?, + val mccMnc: String?) \ No newline at end of file diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/parser/AvpParser.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/parser/AvpParser.kt index 3f793d5e0..52a15ada2 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/parser/AvpParser.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/parser/AvpParser.kt @@ -2,8 +2,7 @@ package org.ostelco.diameter.parser import org.jdiameter.api.Avp import org.jdiameter.api.AvpSet -import org.ostelco.diameter.logger -import org.ostelco.diameter.util.AvpDictionary +import org.ostelco.diameter.getLogger import org.ostelco.diameter.util.AvpType.ADDRESS import org.ostelco.diameter.util.AvpType.APP_ID import org.ostelco.diameter.util.AvpType.FLOAT32 @@ -21,6 +20,8 @@ import org.ostelco.diameter.util.AvpType.UNSIGNED64 import org.ostelco.diameter.util.AvpType.URI import org.ostelco.diameter.util.AvpType.UTF8STRING import org.ostelco.diameter.util.AvpType.VENDOR_ID +import org.ostelco.diameter.util.AvpTypeDictionary +import java.lang.reflect.Field import kotlin.reflect.KClass import kotlin.reflect.KMutableProperty import kotlin.reflect.full.createInstance @@ -28,7 +29,7 @@ import kotlin.reflect.full.declaredMemberProperties class AvpParser { - private val logger by logger() + private val logger by getLogger() /** * @param kclazz Kotlin class representing the data type of the AVP set getting parsed. @@ -54,79 +55,61 @@ class AvpParser { // And 2nd loop using Kotlin reflection to set object field values. // loop over all the fields in that class - clazz.declaredFields - // filter out fields which are not annotated - .filter { - it.isAnnotationPresent(AvpField::class.java) - || it.isAnnotationPresent(AvpList::class.java) + for (field in clazz.declaredFields) { + + if (field.isAnnotationPresent(AvpField::class.java)) { + + // Get numeric Avp ID from annotation on the field + val avpId: Int? = field.getAnnotation(AvpField::class.java)?.avpId + logger.trace("${field.name} id: ($avpId)") + + if (avpId != null) { + + // Get Avp Object from the Set. + // Avp object has AvpCode, Vendor ID, and a value which will be set on object field. + val avp: Avp? = avpSet.getAvp(avpId) + + val avpValue = getAvpValue(field, avp) + + // finally, the value is saved in Map. + // This map is then used in the 2nd loop, where the value is "set" on object field using + // Kotlin reflection + if (avpValue != null) { + logger.trace("${field.name} will be set to $avpValue") + map[field.name] = avpValue + } } - .forEach { - // Get numeric Avp ID from annotation on the field - val avpId: Int? = it.getAnnotation(AvpField::class.java)?.avpId - ?: it.getAnnotation(AvpList::class.java)?.avpId - - logger.trace("${it.name} id: ($avpId)") - if (avpId != null) { - - // Check the data type of the field - val collectionType: KClass<*>? = it.getAnnotation(AvpList::class.java)?.kclass - - // Get Avp Object from the Set. - // Avp object has AvpCode, Vendor ID, and a value which will be set on object field. - val avp: Avp? = avpSet.getAvp(avpId) - - if (avp != null) { - - logger.trace("${it.name} has type ${it.type}") - - val avpValue = when { - // if the target class is Avp itself, the avp object itself is target value - it.type.kotlin == Avp::class -> avp - // The field is of type List. So, even the Avp Value is saved in a list. - // Even though this list has a single value, it helps in distinguishing while setting - // the value back. - it.type.kotlin == List::class -> { - val list = ArrayList() - if (avp.grouped != null && collectionType != null) { - val avpValue = parse(collectionType, avp.grouped) - logger.trace("To list of ${collectionType.simpleName} adding: $avpValue") - list.add(avpValue) - } - list - } - it.type.isEnum -> { - // Fetch int value to be mapped to enum - val intEnum = getAvpValue(it.type.kotlin, avp) as Int - - // Array of enum values for the given enum type of the field - val enumArray = it.type.enumConstants - - try { - // using try block, check if the Enum class has 'value' property - val valueField = it.type.getDeclaredField("value") - enumArray.first { valueField.getInt(it) == intEnum } - } catch (e : Exception) { - // int value is ordinal of enum. So, directly using the enum const array - enumArray[intEnum] - } - } - else -> { - logger.trace("Field: ${it.name}") - // for simple case, fetch target value for given Avp - getAvpValue(it.type.kotlin, avp) - } - } - // finally, the value is saved in Map. - // This map is then used in the 2nd loop, where the value is "set" on object field using - // Kotlin reflection - if (avpValue != null) { - logger.trace("${it.name} will be set to $avpValue") - map[it.name] = avpValue + + } else if (field.isAnnotationPresent(AvpList::class.java)) { + + // Get numeric Avp ID from annotation on the field + val avpId: Int? = field.getAnnotation(AvpList::class.java)?.avpId + logger.trace("${field.name} id: ($avpId)") + + if (avpId != null) { + + // Get Avp Object from the Set. + // Avp object has AvpCode, Vendor ID, and a value which will be set on object field. + val avpFieldSet: AvpSet? = avpSet.getAvps(avpId) + val avpListValue = mutableListOf() + if (avpFieldSet != null) { + for (avp in avpFieldSet) { + getAvpValue(field, avp)?.also { + avpListValue.add(it) } } } - } + // finally, the value is saved in Map. + // This map is then used in the 2nd loop, where the value is "set" on object field using + // Kotlin reflection + if (avpListValue.isNotEmpty()) { + logger.trace("${field.name} will be set to $avpListValue") + map[field.name] = avpListValue + } + } + } + } // Now, the values to be set in object field are ready in the map. // Iterating over fields again, but using Kotlin reflection this time. kclazz.declaredMemberProperties @@ -164,9 +147,59 @@ class AvpParser { return instance } + private fun getAvpValue(field: Field, avp: Avp?): Any? { + + // Check the data type of the field + val collectionType: KClass<*>? = field.getAnnotation(AvpList::class.java)?.kclass + + if (avp != null) { + + logger.trace("${field.name} has type ${field.type}") + + return when { + // if the target class is Avp itself, the avp object itself is target value + field.type.kotlin == Avp::class -> avp + // The field is of type List. So, even the Avp Value is saved in a list. + // Even though this list has a single value, it helps in distinguishing while setting + // the value back. + field.type.kotlin == List::class -> { + if (avp.grouped != null && collectionType != null) { + val avpValue = parse(collectionType, avp.grouped) + logger.trace("To list of ${collectionType.simpleName} adding: $avpValue") + avpValue + } else { + null + } + } + field.type.isEnum -> { + // Fetch int value to be mapped to enum + val intEnum = getAvpValue(field.type.kotlin, avp) as Int + + // Array of enum values for the given enum type of the field + val enumArray = field.type.enumConstants + + try { + // using try block, check if the Enum class has 'value' property + val valueField = field.type.getDeclaredField("value") + enumArray.first { valueField.getInt(field) == intEnum } + } catch (e: Exception) { + // int value is ordinal of enum. So, directly using the enum const array + enumArray[intEnum] + } + } + else -> { + logger.trace("Field: ${field.name}") + // for simple case, fetch target value for given Avp + getAvpValue(field.type.kotlin, avp) + } + } + } + return null + } + private fun getAvpValue(kclazz: KClass<*>, avp: Avp): Any? { - val type = AvpDictionary.getType(avp) + val type = AvpTypeDictionary.getType(avp) logger.trace("Type: $type") diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/AvpDictionary.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/AvpTypeDictionary.kt similarity index 66% rename from diameter-stack/src/main/kotlin/org/ostelco/diameter/util/AvpDictionary.kt rename to diameter-stack/src/main/kotlin/org/ostelco/diameter/util/AvpTypeDictionary.kt index 918a05c52..46a32cea0 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/AvpDictionary.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/AvpTypeDictionary.kt @@ -3,20 +3,35 @@ package org.ostelco.diameter.util import org.jdiameter.api.Avp import org.mobicents.diameter.dictionary.AvpDictionary import org.mobicents.diameter.dictionary.AvpRepresentation -import org.ostelco.diameter.logger -import org.ostelco.diameter.util.AvpType.* +import org.ostelco.diameter.getLogger +import org.ostelco.diameter.util.AvpType.ADDRESS +import org.ostelco.diameter.util.AvpType.IDENTITY +import org.ostelco.diameter.util.AvpType.OCTET_STRING +import org.ostelco.diameter.util.AvpType.UTF8STRING -object AvpDictionary { +object AvpTypeDictionary { - private val LOG by logger() + private val logger by getLogger() private val avpRepMap: MutableMap = HashMap() private val avpTypeMap: MutableMap = HashMap() init { - AvpDictionary.INSTANCE.parseDictionary("config/dictionary.xml") - AvpRep.values().forEach { avpRepMap[it.avpCode] = it.avpType } - AvpType.values().forEach { avpTypeMap[it.label] = it } + try { + AvpDictionary.INSTANCE.parseDictionary(dictionaryPath()) + AvpRep.values().forEach { avpRepMap[it.avpCode] = it.avpType } + AvpType.values().forEach { avpTypeMap[it.label] = it } + } catch (e:Exception) { + logger.error("Failed to init AvpTypeDictionary", e) + } + } + + private fun dictionaryPath(): String { + var configPath: String? = System.getenv("CONFIG_FOLDER") + if (configPath == null) { + configPath = "config" + } + return configPath + "/dictionary.xml" } fun getType(avp: Avp): AvpType? { @@ -32,11 +47,11 @@ object AvpDictionary { avpRep = AvpDictionary.INSTANCE.getAvp(avp.code) } if (avpRep == null) { - LOG.error("AVP ${avp.code} missing in dictionary") + logger.error("AVP ${avp.code} missing in dictionary") return null } - LOG.trace("Type(str): ${avpRep.type}") + logger.trace("Type(str): ${avpRep.type}") avpType = avpTypeMap[avpRep.type] } diff --git a/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/DiameterUtilities.kt b/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/DiameterUtilities.kt index 596f42939..5b42c70d7 100644 --- a/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/DiameterUtilities.kt +++ b/diameter-stack/src/main/kotlin/org/ostelco/diameter/util/DiameterUtilities.kt @@ -3,8 +3,9 @@ package org.ostelco.diameter.util import org.jdiameter.api.Avp import org.jdiameter.api.AvpDataException import org.jdiameter.api.AvpSet +import org.jdiameter.api.validation.AvpRepresentation import org.jdiameter.common.impl.validation.DictionaryImpl -import org.ostelco.diameter.logger +import org.ostelco.diameter.getLogger import org.ostelco.diameter.util.AvpType.ADDRESS import org.ostelco.diameter.util.AvpType.APP_ID import org.ostelco.diameter.util.AvpType.FLOAT32 @@ -25,7 +26,7 @@ import org.ostelco.diameter.util.AvpType.VENDOR_ID class DiameterUtilities { - private val logger by logger() + private val logger by getLogger() private val dictionary = DictionaryImpl.INSTANCE @@ -37,9 +38,9 @@ class DiameterUtilities { private fun printAvps(avps: AvpSet, indentation: String) { for (avp in avps) { - val avpRep = dictionary.getAvp(avp.code, avp.vendorId) + val avpRep : AvpRepresentation? = dictionary.getAvp(avp.code, avp.vendorId) val avpValue = getAvpValue(avp) - val avpLine = StringBuilder("$indentation${avp.code} : ${avpRep.name} (${avpRep.type})") + val avpLine = StringBuilder("$indentation${avp.code} : ${avpRep?.name} (${avpRep?.type})") while (avpLine.length < 50) { avpLine.append(if (avpLine.length % 2 == 0) "." else " ") } @@ -56,7 +57,7 @@ class DiameterUtilities { } private fun getAvpValue(avp: Avp): Any { - val avpType = AvpDictionary.getType(avp) + val avpType = AvpTypeDictionary.getType(avp) return when (avpType) { ADDRESS -> avp.address IDENTITY -> avp.diameterIdentity @@ -78,6 +79,10 @@ class DiameterUtilities { } // TODO martin: for missing Avp, is code and vendorId as 0 okay? - private fun isGrouped(avp: Avp?): Boolean = - ("Grouped" == dictionary.getAvp(avp?.code ?: 0, avp?.vendorId ?: 0).type) + private fun isGrouped(avp: Avp?): Boolean { + if (avp?.code != null) { + return "Grouped" == dictionary.getAvp(avp.code, avp.vendorId)?.type + } + return false + } } diff --git a/diameter-stack/src/test/resources/logback.xml b/diameter-stack/src/test/resources/logback-test.xml similarity index 100% rename from diameter-stack/src/test/resources/logback.xml rename to diameter-stack/src/test/resources/logback-test.xml diff --git a/diameter-test/build.gradle b/diameter-test/build.gradle index c6d970380..e98409685 100644 --- a/diameter-test/build.gradle +++ b/diameter-test/build.gradle @@ -1,5 +1,5 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" id "signing" id "maven" @@ -7,7 +7,9 @@ plugins { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + implementation ("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") { + exclude group: "org.jetbrains.kotlin", module:"kotlin-reflect" + } api project(":diameter-stack") } diff --git a/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestClient.kt b/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestClient.kt index 2b33c0a55..30037a728 100644 --- a/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestClient.kt +++ b/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestClient.kt @@ -8,7 +8,6 @@ import org.jdiameter.api.Configuration import org.jdiameter.api.EventListener import org.jdiameter.api.IllegalDiameterStateException import org.jdiameter.api.InternalException -import org.jdiameter.api.Message import org.jdiameter.api.Mode import org.jdiameter.api.Network import org.jdiameter.api.NetworkReqListener @@ -21,20 +20,18 @@ import org.jdiameter.api.Stack import org.jdiameter.common.impl.app.cca.JCreditControlRequestImpl import org.jdiameter.server.impl.StackImpl import org.jdiameter.server.impl.helpers.XMLConfiguration -import org.ostelco.diameter.logger +import org.ostelco.diameter.getLogger +import org.ostelco.diameter.model.RequestType import org.ostelco.diameter.util.DiameterUtilities import java.util.concurrent.TimeUnit class TestClient : EventListener { - private val logger by logger() + private val logger by getLogger() companion object { - //configuration files - private const val configFile = "client-jdiameter-config.xml" - // definition of codes, IDs private const val applicationID = 4L // Diameter Credit Control Application (4) @@ -73,7 +70,7 @@ class TestClient : EventListener { * * @param configPath path to the jDiameter configuration file */ - fun initStack(configPath: String) { + fun initStack(configPath: String, configFile: String) { try { config = XMLConfiguration(configPath + configFile) } catch (e: Exception) { @@ -92,13 +89,12 @@ class TestClient : EventListener { network.addNetworkReqListener( NetworkReqListener { request -> logger.info("Got a request") - resultAvps = request.getAvps() + resultAvps = request.avps DiameterUtilities().printAvps(resultAvps) isRequestReceived = true null }, this.authAppId) //passing our example app id. - } catch (e: Exception) { logger.error("Failed to init Diameter Stack", e) this.stack.destroy() @@ -179,7 +175,7 @@ class TestClient : EventListener { val ccr = JCreditControlRequestImpl(request) try { session.send(ccr.message, this) - dumpMessage(ccr.message, true) //dump info on console + logger.info("Sending request of type [" + RequestType.getTypeAsString(ccr.requestTypeAVPValue) + "]") return true } catch (e: InternalException) { logger.error("Failed to send request", e) @@ -197,7 +193,7 @@ class TestClient : EventListener { } override fun receivedSuccessMessage(request: Request, answer: Answer) { - dumpMessage(answer, false) + logger.info("Received answer") resultAvps = answer.avps resultCodeAvp = answer.resultCode this.isAnswerReceived = true @@ -207,17 +203,6 @@ class TestClient : EventListener { logger.info("Timeout expired $request") } - - private fun dumpMessage(message: Message, sending: Boolean) { - logger.info((if (sending) "Sending " else "Received ") - + (if (message.isRequest) "Request: " else "Answer: ") + message.commandCode - + "\nE2E:" + message.endToEndIdentifier - + "\nHBH:" + message.hopByHopIdentifier - + "\nAppID:" + message.applicationId) - - logger.info("AVPS[" + message.avps.size() + "]: \n") - } - /** * Shut down the Diameter Stack */ diff --git a/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestHelper.kt b/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestHelper.kt index 07d015f4e..b81f72ef6 100644 --- a/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestHelper.kt +++ b/diameter-test/src/main/kotlin/org/ostelco/diameter/test/TestHelper.kt @@ -42,20 +42,26 @@ object TestHelper { } } - private fun addBucketRequest(ccrAvps: AvpSet, ratingGroup: Int, serviceIdentifier: Int, bucketSize: Long, usedBucketSize: Long = 0) { + private fun addBucketRequest(ccrAvps: AvpSet, ratingGroup: Int, serviceIdentifier: Int, requestedBucketSize: Long, usedBucketSize: Long = 0) { set(ccrAvps) { avp(Avp.MULTIPLE_SERVICES_INDICATOR, 1, pFlag = true) group(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) { - avp(Avp.RATING_GROUP, ratingGroup, pFlag = true) - avp(Avp.SERVICE_IDENTIFIER_CCA, serviceIdentifier, pFlag = true) + if (ratingGroup > 0) { + avp(Avp.RATING_GROUP, ratingGroup, pFlag = true) + } + if (serviceIdentifier > 0) { + avp(Avp.SERVICE_IDENTIFIER_CCA, serviceIdentifier, pFlag = true) + } group(Avp.REQUESTED_SERVICE_UNIT) { - avp(Avp.CC_TOTAL_OCTETS, bucketSize, pFlag = true) - avp(Avp.CC_INPUT_OCTETS, 0L, pFlag = true) - avp(Avp.CC_OUTPUT_OCTETS, 0L, pFlag = true) + if (requestedBucketSize > 0) { + avp(Avp.CC_TOTAL_OCTETS, requestedBucketSize, pFlag = true) + avp(Avp.CC_INPUT_OCTETS, 0L, pFlag = true) + avp(Avp.CC_OUTPUT_OCTETS, 0L, pFlag = true) + } } if (usedBucketSize > 0) { @@ -68,7 +74,7 @@ object TestHelper { } } - private fun addFinalBucketRequest(ccrAvps: AvpSet, ratingGroup: Int, serviceIdentifier: Int) { + private fun addFinalBucketRequest(ccrAvps: AvpSet, ratingGroup: Int, serviceIdentifier: Int, usedBucketSize: Long = 0) { set(ccrAvps) { @@ -76,6 +82,7 @@ object TestHelper { group(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL) { group(Avp.USED_SERVICE_UNIT) { + avp(Avp.CC_TOTAL_OCTETS, usedBucketSize, pFlag = true) avp(Avp.CC_TIME, 0, pFlag = true) avp(Avp.CC_SERVICE_SPECIFIC_UNITS, 0L, pFlag = true) } @@ -86,7 +93,7 @@ object TestHelper { } } - private fun addTerminateRequest(ccrAvps: AvpSet, ratingGroup: Int, serviceIdentifier: Int, bucketSize: Long) { + private fun addTerminateRequest(ccrAvps: AvpSet, ratingGroup: Int, serviceIdentifier: Int, usedBucketSize: Long) { set(ccrAvps) { @@ -97,7 +104,7 @@ object TestHelper { avp(Avp.SERVICE_IDENTIFIER_CCA, serviceIdentifier, pFlag = true) group(Avp.USED_SERVICE_UNIT) { - avp(Avp.CC_TOTAL_OCTETS, bucketSize, pFlag = true) + avp(Avp.CC_TOTAL_OCTETS, usedBucketSize, pFlag = true) avp(Avp.CC_INPUT_OCTETS, 0L, pFlag = true) avp(Avp.CC_OUTPUT_OCTETS, 0L, pFlag = true) avp(Avp.CC_SERVICE_SPECIFIC_UNITS, 0L, pFlag = true) @@ -120,37 +127,54 @@ object TestHelper { } } + @JvmStatic + fun addUnknownApv(ccrAvps: AvpSet) { + set(ccrAvps) { + avp(950, "Unknown AVP", vendorId = 2011, asOctetString = true, pFlag = false, mFlag = false) + } + } + @JvmStatic - fun createInitRequest(ccrAvps: AvpSet, msisdn: String, bucketSize: Long) { + fun createInitRequest(ccrAvps: AvpSet, msisdn: String, requestedBucketSize: Long, ratingGroup: Int, serviceIdentifier: Int) { + buildBasicRequest(ccrAvps, RequestType.INITIAL_REQUEST, requestNumber = 0) + addUser(ccrAvps, msisdn = msisdn, imsi = IMSI) + addBucketRequest(ccrAvps, ratingGroup, serviceIdentifier, requestedBucketSize = requestedBucketSize) + addServiceInformation(ccrAvps, apn = APN, sgsnMccMnc = SGSN_MCC_MNC) + } + + @JvmStatic + fun createInitRequestMultiRatingGroups(ccrAvps: AvpSet, msisdn: String, requestedBucketSize: Long) { buildBasicRequest(ccrAvps, RequestType.INITIAL_REQUEST, requestNumber = 0) addUser(ccrAvps, msisdn = msisdn, imsi = IMSI) - addBucketRequest(ccrAvps, ratingGroup = 10, serviceIdentifier = 1, bucketSize = bucketSize) + addBucketRequest(ccrAvps, ratingGroup = 10, serviceIdentifier = 1, requestedBucketSize = requestedBucketSize) + addBucketRequest(ccrAvps, ratingGroup = 12, serviceIdentifier = 2, requestedBucketSize = requestedBucketSize) + addBucketRequest(ccrAvps, ratingGroup = 14, serviceIdentifier = 4, requestedBucketSize = requestedBucketSize) addServiceInformation(ccrAvps, apn = APN, sgsnMccMnc = SGSN_MCC_MNC) } @JvmStatic - fun createUpdateRequest(ccrAvps: AvpSet, msisdn: String, bucketSize: Long, usedBucketSize: Long) { + fun createUpdateRequest(ccrAvps: AvpSet, msisdn: String, requestedBucketSize: Long, usedBucketSize: Long, ratingGroup: Int, serviceIdentifier: Int) { buildBasicRequest(ccrAvps, RequestType.UPDATE_REQUEST, requestNumber = 1) addUser(ccrAvps, msisdn = msisdn, imsi = IMSI) - addBucketRequest(ccrAvps, ratingGroup = 10, serviceIdentifier = 1, bucketSize = bucketSize, usedBucketSize = usedBucketSize) + addBucketRequest(ccrAvps, ratingGroup, serviceIdentifier, requestedBucketSize = requestedBucketSize, usedBucketSize = usedBucketSize) addServiceInformation(ccrAvps, apn = APN, sgsnMccMnc = SGSN_MCC_MNC) } @JvmStatic - fun createUpdateRequestFinal(ccrAvps: AvpSet, msisdn: String) { + fun createUpdateRequestFinal(ccrAvps: AvpSet, msisdn: String, usedBucketSize: Long, ratingGroup: Int, serviceIdentifier: Int) { buildBasicRequest(ccrAvps, RequestType.UPDATE_REQUEST, requestNumber = 1) addUser(ccrAvps, msisdn = msisdn, imsi = IMSI) - addFinalBucketRequest(ccrAvps, ratingGroup = 10, serviceIdentifier = 1) + addFinalBucketRequest(ccrAvps, ratingGroup, serviceIdentifier, usedBucketSize = usedBucketSize) addServiceInformation(ccrAvps, apn = APN, sgsnMccMnc = SGSN_MCC_MNC) } @JvmStatic - fun createTerminateRequest(ccrAvps: AvpSet, msisdn: String, bucketSize: Long) { + fun createTerminateRequest(ccrAvps: AvpSet, msisdn: String, usedBucketSize: Long, ratingGroup: Int, serviceIdentifier: Int) { buildBasicRequest(ccrAvps, RequestType.TERMINATION_REQUEST, requestNumber = 2) addUser(ccrAvps, msisdn = msisdn, imsi = IMSI) - addTerminateRequest(ccrAvps, ratingGroup = 10, serviceIdentifier = 1, bucketSize = bucketSize) + addTerminateRequest(ccrAvps, ratingGroup, serviceIdentifier, usedBucketSize = usedBucketSize) addServiceInformation(ccrAvps, apn = APN, sgsnMccMnc = SGSN_MCC_MNC) } diff --git a/diameter-test/src/main/resources/logback.xml b/diameter-test/src/main/resources/logback.xml index 31e4c21df..46685d865 100644 --- a/diameter-test/src/main/resources/logback.xml +++ b/diameter-test/src/main/resources/logback.xml @@ -3,7 +3,7 @@ - %d{dd MMM yyyy HH:mm:ss,SSS} %-4r [%t] %-5p %c %x - %m%n + %d{dd MMM yyyy HH:mm:ss,SSS} %highlight(%-4r) %-5p %c %x - %m%n diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml deleted file mode 100644 index e75bd7443..000000000 --- a/docker-compose.dev.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: "3.3" - -services: - ocsgw: - environment: - - OCS_GRPC_SERVER=ocs.dev.ostelco.org - - METRICS_GRPC_SERVER=metrics.dev.ostelco.org - network_mode: "host" \ No newline at end of file diff --git a/docker-compose.esp.yaml b/docker-compose.esp.yaml new file mode 100644 index 000000000..f2d07e454 --- /dev/null +++ b/docker-compose.esp.yaml @@ -0,0 +1,159 @@ +version: "3.7" + +services: + prime: + container_name: prime + build: + context: prime + dockerfile: Dockerfile.test + environment: + - GCP_PROJECT_ID=${GCP_PROJECT_ID} + - FIREBASE_ROOT_PATH=test + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - JUMIO_API_TOKEN= + - JUMIO_API_SECRET= + - STRIPE_API_KEY=${STRIPE_API_KEY} + - STRIPE_ENDPOINT_SECRET=${STRIPE_ENDPOINT_SECRET} + - DATASTORE_EMULATOR_HOST=localhost:9090 + - DATASTORE_PROJECT_ID=${GCP_PROJECT_ID} + - MANDRILL_API_KEY= + - LOCAL_TESTING=true + ports: + - "9090:8080" + - "8082:8082" + depends_on: + - "ext-auth-provider" + - "datastore-emulator" + - "pubsub-emulator" + - "neo4j" + command: ["/bin/bash", "./wait.sh"] + tmpfs: + - /data + networks: + net: + aliases: + - "prime" + ipv4_address: 172.16.238.5 + default: + + esp: + container_name: esp + image: gcr.io/endpoints-release/endpoints-runtime:1 + volumes: + - "./prime/config:/esp" + - "./certs/ocs.dev.ostelco.org:/etc/nginx/ssl" + command: > + --service=ocs.dev.ostelco.org + --rollout_strategy=managed + --http2_port=80 + --ssl_port=443 + --backend=grpc://172.16.238.5:8082 + --service_account_key=/esp/prime-service-account.json + networks: + net: + aliases: + - "ocs.dev.ostelco.org" + ipv4_address: 172.16.238.4 + default: + + metrics-esp: + container_name: metrics-esp + image: gcr.io/endpoints-release/endpoints-runtime:1 + volumes: + - "./prime/config:/esp" + - "./certs/metrics.dev.ostelco.org:/etc/nginx/ssl" + command: > + --service=metrics.dev.ostelco.org + --rollout_strategy=managed + --http2_port=80 + --ssl_port=443 + --backend=grpc://172.16.238.5:8083 + --service_account_key=/esp/prime-service-account.json + networks: + net: + aliases: + - "metrics.dev.ostelco.org" + ipv4_address: 172.16.238.6 + default: + + ocsgw: + container_name: ocsgw + build: ocsgw + depends_on: + - "prime" + command: ["./wait_including_esps.sh"] + environment: + - OCS_GRPC_SERVER=ocs.dev.ostelco.org + - METRICS_GRPC_SERVER=metrics.dev.ostelco.org + - SERVICE_FILE=prime-service-account.json + - GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID} + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - PUBSUB_CCR_TOPIC_ID=ocs-ccr + - PUBSUB_CCA_TOPIC_ID=ocs-cca + - PUBSUB_CCA_SUBSCRIPTION_ID=ocsgw-cca-sub + - PUBSUB_ACTIVATE_SUBSCRIPTION_ID=ocsgw-activate-sub + volumes: + - ./ocsgw/cert:/cert/ + - ./ocsgw/config:/config/ + networks: + net: + aliases: + - "ocsgw" + ipv4_address: 172.16.238.3 + default: + + acceptance-tests: + container_name: acceptance-tests + build: acceptance-tests + depends_on: + - "ocsgw" + - "prime" + command: ["./wait.sh"] + environment: + - PRIME_SOCKET=prime:8080 + - OCS_SOCKET=prime:8082 + - STRIPE_API_KEY=${STRIPE_API_KEY} + - GOOGLE_APPLICATION_CREDENTIALS=/secret/prime-service-account.json + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + networks: + net: + ipv4_address: 172.16.238.2 + default: + + neo4j: + container_name: "neo4j" + image: neo4j:3.4.9 + environment: + - NEO4J_AUTH=none + ports: + - "7687:7687" + - "7474:7474" + tmpfs: "/data" + + pubsub-emulator: + container_name: pubsub-emulator + image: knarz/pubsub-emulator + + datastore-emulator: + container_name: datastore-emulator + image: google/cloud-sdk:218.0.0 + expose: + - "8081" + environment: + - CLOUDSDK_CORE_PROJECT=${GCP_PROJECT_ID} + - DATASTORE_DATASET=${GCP_PROJECT_ID} + command: ["gcloud", "beta", "emulators", "datastore", "start", "--host-port=0.0.0.0:8081"] + + ext-auth-provider: + container_name: ext-auth-provider + build: ext-auth-provider + +networks: + net: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.16.238.0/24 diff --git a/docker-compose.ocs.yaml b/docker-compose.ocs.yaml new file mode 100644 index 000000000..72a5d2519 --- /dev/null +++ b/docker-compose.ocs.yaml @@ -0,0 +1,77 @@ +version: "3.7" + +services: + prime: + container_name: prime + build: + context: prime + dockerfile: Dockerfile.test + environment: + - GCP_PROJECT_ID=${GCP_PROJECT_ID} + - FIREBASE_ROOT_PATH=test + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - JUMIO_API_TOKEN= + - JUMIO_API_SECRET= + - STRIPE_API_KEY=${STRIPE_API_KEY} + - STRIPE_ENDPOINT_SECRET=${STRIPE_ENDPOINT_SECRET} + - DATASTORE_EMULATOR_HOST=localhost:9090 + - DATASTORE_PROJECT_ID=${GCP_PROJECT_ID} + - MANDRILL_API_KEY= + - LOCAL_TESTING=true + - LOAD_TESTING=true + ports: + - "9090:8080" + - "8082:8082" + depends_on: + - "ext-auth-provider" + - "datastore-emulator" + - "pubsub-emulator" + - "neo4j" + command: ["/bin/bash", "./wait.sh"] + tmpfs: + - /data + networks: + net: + aliases: + - "prime" + ipv4_address: 172.16.238.5 + default: + + neo4j: + container_name: "neo4j" + image: neo4j:3.4.9 + environment: + - NEO4J_AUTH=none + ports: + - "7687:7687" + - "7474:7474" + tmpfs: "/data" + + pubsub-emulator: + container_name: pubsub-emulator + image: knarz/pubsub-emulator + ports: + - "8085:8085" + + datastore-emulator: + container_name: datastore-emulator + image: google/cloud-sdk:218.0.0 + expose: + - "8081" + environment: + - CLOUDSDK_CORE_PROJECT=${GCP_PROJECT_ID} + - DATASTORE_DATASET=${GCP_PROJECT_ID} + command: ["gcloud", "beta", "emulators", "datastore", "start", "--host-port=0.0.0.0:8081"] + + ext-auth-provider: + container_name: ext-auth-provider + build: ext-auth-provider + +networks: + net: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.16.238.0/24 diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml deleted file mode 100644 index 5ca420ea3..000000000 --- a/docker-compose.override.yaml +++ /dev/null @@ -1,135 +0,0 @@ -version: "3.3" - -services: - prime: - container_name: prime - build: - context: prime - dockerfile: Dockerfile.test - environment: - - FIREBASE_ROOT_PATH=test - - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 - - PUBSUB_PROJECT_ID=pantel-2decb - - STRIPE_API_KEY=${STRIPE_API_KEY} - - DATASTORE_EMULATOR_HOST=localhost:9090 - - DATASTORE_PROJECT_ID=pantel-2decb - - LOCAL_TESTING=true - ports: - - "9090:8080" - depends_on: - - "pubsub-emulator" - - "neo4j" - command: ["/bin/bash", "./wait.sh"] - tmpfs: - - /data - networks: - net: - aliases: - - "prime" - ipv4_address: 172.16.238.5 - default: - - esp: - container_name: esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - volumes: - - "./prime/config:/esp" - - "./certs/ocs.dev.ostelco.org:/etc/nginx/ssl" - command: > - --service=ocs.dev.ostelco.org - --rollout_strategy=managed - --http2_port=80 - --ssl_port=443 - --backend=grpc://172.16.238.5:8082 - --service_account_key=/esp/pantel-prod.json - networks: - net: - aliases: - - "ocs.dev.ostelco.org" - ipv4_address: 172.16.238.4 - default: - - metrics-esp: - container_name: metrics-esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - volumes: - - "./prime/config:/esp" - - "./certs/metrics.dev.ostelco.org:/etc/nginx/ssl" - command: > - --service=metrics.dev.ostelco.org - --rollout_strategy=managed - --http2_port=80 - --ssl_port=443 - --backend=grpc://172.16.238.5:8083 - --service_account_key=/esp/pantel-prod.json - networks: - net: - aliases: - - "metrics.dev.ostelco.org" - ipv4_address: 172.16.238.6 - default: - - ocsgw: - environment: - - OCS_GRPC_SERVER=ocs.dev.ostelco.org - - METRICS_GRPC_SERVER=metrics.dev.ostelco.org - depends_on: - - "prime" - command: ["./wait.sh"] - networks: - net: - aliases: - - "ocsgw" - ipv4_address: 172.16.238.3 - - acceptance-tests: - container_name: acceptance-tests - build: acceptance-tests - depends_on: - - "ocsgw" - - "prime" - command: ["./wait.sh"] - environment: - - PRIME_SOCKET=prime:8080 - - STRIPE_API_KEY=${STRIPE_API_KEY} - - GOOGLE_APPLICATION_CREDENTIALS=/secret/pantel-prod.json - networks: - net: - ipv4_address: 172.16.238.2 - default: - - neo4j: - container_name: "neo4j" - image: neo4j:3.4.8 - environment: - - NEO4J_AUTH=none - ports: - - "7687:7687" - - "7474:7474" - tmpfs: "/data" - - pubsub-emulator: - container_name: pubsub-emulator - image: knarz/pubsub-emulator - - datastore-emulator: - container_name: datastore-emulator - image: google/cloud-sdk:218.0.0 - expose: - - "8081" - environment: - - CLOUDSDK_CORE_PROJECT=pantel-2decb - - DATASTORE_DATASET=pantel-2decb - command: ["gcloud", "beta", "emulators", "datastore", "start", "--host-port=0.0.0.0:8081"] - - ext-auth-provider: - container_name: ext-auth-provider - build: ext-auth-provider - -networks: - net: - driver: bridge - ipam: - driver: default - config: - - subnet: 172.16.238.0/24 diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml deleted file mode 100644 index 5d5dd67fa..000000000 --- a/docker-compose.prod.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: "3.3" - -services: - ocsgw: - environment: - - OCS_GRPC_SERVER=ocs.ostelco.org - - METRICS_GRPC_SERVER=metrics.ostelco.org - network_mode: "host" \ No newline at end of file diff --git a/docker-compose.seagull.yaml b/docker-compose.seagull.yaml new file mode 100644 index 000000000..2dffb8c80 --- /dev/null +++ b/docker-compose.seagull.yaml @@ -0,0 +1,103 @@ +version: "3.7" + +services: + prime: + container_name: prime + build: + context: prime + dockerfile: Dockerfile.test + environment: + - GCP_PROJECT_ID=${GCP_PROJECT_ID} + - FIREBASE_ROOT_PATH=test + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - JUMIO_API_TOKEN= + - JUMIO_API_SECRET= + - STRIPE_API_KEY=${STRIPE_API_KEY} + - STRIPE_ENDPOINT_SECRET=${STRIPE_ENDPOINT_SECRET} + - DATASTORE_EMULATOR_HOST=localhost:9090 + - DATASTORE_PROJECT_ID=${GCP_PROJECT_ID} + - MANDRILL_API_KEY= + - LOCAL_TESTING=true + ports: + - "9090:8080" + - "8082:8082" + depends_on: + - "datastore-emulator" + - "pubsub-emulator" + - "neo4j" + command: ["/bin/bash", "./wait.sh"] + tmpfs: + - /data + networks: + net: + aliases: + - "prime" + ipv4_address: 172.16.238.5 + default: + + ocsgw: + container_name: ocsgw + build: ocsgw + depends_on: + - "pubsub-emulator" + - "prime" + command: ["./wait.sh"] + environment: + - DISABLE_TLS=true + - OCS_GRPC_SERVER=prime:8082 + - METRICS_GRPC_SERVER=prime:8083 + - SERVICE_FILE=prime-service-account.json + - GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID} + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - PUBSUB_CCR_TOPIC_ID=ocs-ccr + - PUBSUB_CCA_TOPIC_ID=ocs-cca + - PUBSUB_CCA_SUBSCRIPTION_ID=ocsgw-cca-sub + - PUBSUB_ACTIVATE_SUBSCRIPTION_ID=ocsgw-activate-sub + - DIAMETER_CONFIG_FILE=server-jdiameter-config.xml + - OCS_DATASOURCE_TYPE=Local + - CONFIG_FOLDER=/config/ + - "JAVA_OPTS=-Xms512m -Xmx1024m -server" + volumes: + - ./ocsgw/cert:/cert/ + - ./ocsgw/config:/config/ + networks: + net: + aliases: + - "ocsgw" + ipv4_address: 172.16.238.3 + default: + + neo4j: + container_name: "neo4j" + image: neo4j:3.4.9 + environment: + - NEO4J_AUTH=none + ports: + - "7687:7687" + - "7474:7474" + tmpfs: "/data" + + pubsub-emulator: + container_name: pubsub-emulator + image: knarz/pubsub-emulator + + datastore-emulator: + container_name: datastore-emulator + image: google/cloud-sdk:218.0.0 + expose: + - "8081" + environment: + - CLOUDSDK_CORE_PROJECT=${GCP_PROJECT_ID} + - DATASTORE_DATASET=${GCP_PROJECT_ID} + command: ["gcloud", "beta", "emulators", "datastore", "start", "--host-port=0.0.0.0:8081"] + + +networks: + net: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.16.238.0/24 diff --git a/docker-compose.yaml b/docker-compose.yaml index 9895eeda0..8b3e88169 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,14 +1,126 @@ version: "3.3" services: + prime: + container_name: prime + build: + context: prime + dockerfile: Dockerfile.test + environment: + - GCP_PROJECT_ID=${GCP_PROJECT_ID} + - FIREBASE_ROOT_PATH=test + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - JUMIO_API_TOKEN= + - JUMIO_API_SECRET= + - STRIPE_API_KEY=${STRIPE_API_KEY} + - STRIPE_ENDPOINT_SECRET=${STRIPE_ENDPOINT_SECRET} + - DATASTORE_EMULATOR_HOST=localhost:9090 + - DATASTORE_PROJECT_ID=${GCP_PROJECT_ID} + - MANDRILL_API_KEY= + - LOCAL_TESTING=true + ports: + - "9090:8080" + - "8082:8082" + depends_on: + - "ext-auth-provider" + - "datastore-emulator" + - "pubsub-emulator" + - "neo4j" + command: ["/bin/bash", "./wait.sh"] + tmpfs: + - /data + networks: + net: + aliases: + - "prime" + ipv4_address: 172.16.238.5 + default: + ocsgw: container_name: ocsgw build: ocsgw + depends_on: + - "pubsub-emulator" + - "prime" + command: ["./wait.sh"] environment: - - GOOGLE_APPLICATION_CREDENTIALS=/config/pantel-prod.json - - GOOGLE_CLOUD_PROJECT=pantel-2decb - auth-server: - container_name: auth-server - build: auth-server + - DISABLE_TLS=true + - OCS_GRPC_SERVER=prime:8082 + - METRICS_GRPC_SERVER=prime:8083 + - SERVICE_FILE=prime-service-account.json + - GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID} + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - PUBSUB_CCR_TOPIC_ID=ocs-ccr + - PUBSUB_CCA_TOPIC_ID=ocs-cca + - PUBSUB_CCA_SUBSCRIPTION_ID=ocsgw-cca-sub + - PUBSUB_ACTIVATE_SUBSCRIPTION_ID=ocsgw-activate-sub + - OCS_DATASOURCE_TYPE=Proxy + volumes: + - ./ocsgw/cert:/cert/ + - ./ocsgw/config:/config/ + networks: + net: + aliases: + - "ocsgw" + ipv4_address: 172.16.238.3 + default: + + acceptance-tests: + container_name: acceptance-tests + build: acceptance-tests + depends_on: + - "ocsgw" + - "prime" + command: ["./wait.sh"] + environment: + - PRIME_SOCKET=prime:8080 + - OCS_SOCKET=prime:8082 + - STRIPE_API_KEY=${STRIPE_API_KEY} + - GOOGLE_APPLICATION_CREDENTIALS=/secret/prime-service-account.json + - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 + networks: + net: + ipv4_address: 172.16.238.2 + default: + + ext-myinfo-emulator: + container_name: ext-myinfo-emulator + build: ext-myinfo-emulator + + neo4j: + container_name: "neo4j" + image: neo4j:3.4.9 + environment: + - NEO4J_AUTH=none ports: - - "8080:8080" + - "7687:7687" + - "7474:7474" + tmpfs: "/data" + + pubsub-emulator: + container_name: pubsub-emulator + image: knarz/pubsub-emulator + + datastore-emulator: + container_name: datastore-emulator + image: google/cloud-sdk:218.0.0 + expose: + - "8081" + environment: + - CLOUDSDK_CORE_PROJECT=${GCP_PROJECT_ID} + - DATASTORE_DATASET=${GCP_PROJECT_ID} + command: ["gcloud", "beta", "emulators", "datastore", "start", "--host-port=0.0.0.0:8081"] + + ext-auth-provider: + container_name: ext-auth-provider + build: ext-auth-provider + +networks: + net: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.16.238.0/24 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index dd72d41ea..afcb81517 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,58 +1,17 @@ # Deploy to production -## Deploy to on-premise adjoining Packet gateway +## Deploy OCSgw to GCP +./ocsgw/infra/script/deploy-ocsgw.sh -### TL;DR - -```bash -gradle clean pack -scripts/deploy-ocsgw.sh -``` - - -### Package - - gradle clean pack - -With unit testing: - - gradle clean test pack - -* This creates zip file `build/deploy/ostelco-core.zip` - -### Deploy on host - -* Upload and unzip `ostelco-core.zip` file. - -```bash -scp -oProxyJump=loltel@10.6.101.1 build/deploy/ostelco-core.zip ubuntu@192.168.0.123:. -ssh -A -Jloltel@10.6.101.1 ubuntu@192.168.0.123 -cd ostelco-core -sudo docker-compose down -cd .. -rm -rf ostelco-core -unzip ostelco-core.zip -d ostelco-core -``` - -* Run in docker - -```bash -cd ostelco-core -sudo docker-compose up -d --build - -sudo docker-compose logs -f - -sudo docker logs -f ocsgw -sudo docker logs -f auth-server -``` - +The script takes to parameters. First parameter is instance number [1/2/3]. Second parameter is environment [dev/prod]. +If no parameters passed it will deploy all instances in dev environment. ## Deploy to kubernetes cluster on GCP Set env variable - export PROJECT_ID="$(gcloud config get-value project -q)" + export GCP_PROJECT_ID="$(gcloud config get-value project -q)" For the commands below: @@ -77,11 +36,11 @@ If cluster already exists, fetch authentication credentials for the Kubernetes c Build the Docker image (In the folder with Dockerfile) - docker build -t eu.gcr.io/${PROJECT_ID}/: . + docker build -t eu.gcr.io/${GCP_PROJECT_ID}/: . Push to the registry - docker push eu.gcr.io/${PROJECT_ID}/: + docker push eu.gcr.io/${GCP_PROJECT_ID}/: Apply the deployment diff --git a/docs/DEV.md b/docs/DEV.md index 399469867..914de4acd 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -2,7 +2,9 @@ ## Checking for dependency updates - gradle dependencyUpdates -Drevision=release +```bash +./gradlew dependencyUpdates -Drevision=release +``` ## Package / Namespace naming convention diff --git a/docs/EXPERIMENTAL_AGENTS_LIFECYCLE.md b/docs/EXPERIMENTAL_AGENTS_LIFECYCLE.md index 551a384fa..c0d2037bd 100644 --- a/docs/EXPERIMENTAL_AGENTS_LIFECYCLE.md +++ b/docs/EXPERIMENTAL_AGENTS_LIFECYCLE.md @@ -234,7 +234,7 @@ Notes Make a certificate https://console.cloud.google.com/apis/credentials To authenticate - export GOOGLE_APPLICATION_CREDENTIALS=/some/path/pantel-credentials.json + export GOOGLE_APPLICATION_CREDENTIALS=/some/path/prime-service-account.json then @@ -255,14 +255,14 @@ To authenticvate Authenticate Set your project: - gcloud config set project pantel + gcloud config set project GCP_PROJECT_ID .. or some other project Run a script to get some data, e.g. - bq head -n 10 pantel-2decb:data_consumption.hourly_consumption + bq head -n 10 $GCP_PROJECT_ID:data_consumption.hourly_consumption to get ten lines of consumption data displayed. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index e8e98616e..1e812eb40 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -14,18 +14,18 @@ * **Experiment** is performed by defining and exercising one or more **Offers**. ### Offer - * **Offer** aims to target a **Segment** of subscribers with the **Product(s)**. + * **Offer** aims to target a **Segment** of customers with the **Product(s)**. * Thus **Offer** is composition of **Segment** and **Product(s)**. ### Segment - * **Segment** of subscribers is a _improper_ subset of all subscribers. - * **Segment** is defined as a condition which will be true for those the set of subscribers. - * Outcome of applying a **Segment** filter is a list of subscribers. - * So, (unlikely but possible) **segment** may directly be a list of subscribers without an explicit _filter condition_. - * **Subscribers** may join/leave segment dynamically. + * **Segment** of customers is a _improper_ subset of all customers. + * **Segment** is defined as a condition which will be true for those the set of customers. + * Outcome of applying a **Segment** filter is a list of customers. + * So, (unlikely but possible) **segment** may directly be a list of customers without an explicit _filter condition_. + * **Customers** may join/leave segment dynamically. * Schedule/frequency of **Segment** _membership_ is upto technical feasibility. * If the **Segment** _membership_ is updated during its associated with an _Active_ **Offer**, then the **Product(s)** - offering to subscribers belonging to that segment should correspondingly updated. + offering to customers belonging to that segment should correspondingly updated. * Segment can exist without Offer. ### Product @@ -53,7 +53,7 @@ * Product Class - `FREE_NETFLIX_FOR_PERIOD` has properties as `start_date`, `end_date`. Here, `Netflix` is part of SKU and is not an explicitly defined property. ### Resource - * Resource is the _commodity_ which is consumed by subscriber. + * Resource is the _commodity_ which is consumed by customer. * It is a simple _enum_ having values such as: * Data * Voice @@ -74,9 +74,9 @@ ### Resource Bundle * **Resource Bundle** is like an account where the **Resource** is _credited_ or _debited_. - * All of the **Resource Bundle** might not be exposed to subscriber. + * All of the **Resource Bundle** might not be exposed to customer. * This account need not be _positive balanced_, - meaning subscriber consumption leading to _negative_ balance may be permitted. + meaning customer consumption leading to _negative_ balance may be permitted. * In case of Prepaid, the account is first _credited_ by using _Topup_ operation, and then _debited_ from due to consumption. * Based on _Resource Consumption Context_, **Resource** may be consumed from _other_ type of **Resource Bundle**. @@ -86,4 +86,11 @@ * A _Topup_ operation is a purchase of (one or more) _topup_ product(s), which in turn results in _Credit_ to (one or more) **Resource Bundles**. * It is subtype of _Purchase_ operation, which include purchase of other products, such as `SIM card` or `Topup Voucher`. - Thus, in case of `Topup voucher`, actual **Topup operation** happens when Voucher is _claimed_ and not when it is _purchased_. + Thus, in case of `Topup voucher`, actual **Topup operation** happens when Voucher is _claimed_ and not when it is _purchased_. + +### Region + * Region can be: + * Country + * Part of a country + * Set of multiple countries + diff --git a/docs/LOGS.md b/docs/LOGS.md index f3c694e96..7578c7ec8 100644 --- a/docs/LOGS.md +++ b/docs/LOGS.md @@ -4,22 +4,19 @@ ## To view logs -### Direct link - * [Direct link to prime logs](https://console.cloud.google.com/logs/viewer?project=pantel-2decb&minLogLevel=0&expandAll=false&resource=container%2Fcluster_name%2Fprivate-cluster%2Fnamespace_id%2Fdefault&scrollTimestamp=2018-05-09T11%3A54%3A03.000000000Z&dateRangeStart=2018-05-09T10%3A55%3A37.736Z&dateRangeEnd=2018-05-09T11%3A55%3A37.736Z&interval=PT1H&customFacets&limitCustomFacetWidth=true&advancedFilter=resource.type%3D%22container%22%0Aresource.labels.cluster_name%3D%22private-cluster%22%0Aresource.labels.namespace_id%3D%22default%22%0AlogName%3D%22projects%2Fpantel-2decb%2Flogs%2Fprime%22) - ### Advanced filter - * Goto [this link](https://console.cloud.google.com/logs/viewer?project=pantel-2decb) + * Goto [this link](https://console.cloud.google.com/logs/viewer?project=GCP_PROJECT_ID) * Open hidden-menu from right of Search bar and select `Convert to advanced filter` ```properties resource.type="container" -resource.labels.cluster_name="private-cluster" -logName="projects/pantel-2decb/logs/prime" +resource.labels.namespace_id="dev" +resource.labels.container_name="prime" ``` ### Basic filter - * Goto [this link](https://console.cloud.google.com/logs/viewer?project=pantel-2decb) + * Goto [this link](https://console.cloud.google.com/logs/viewer?project=GCP_PROJECT_ID) * GKE container > private-cluster > All namespace_id * You can expand a single log and filter to log prime-only logs. @@ -29,5 +26,5 @@ Same steps as above. Use the filter below: ```properties resource.type="global" -logName="projects/pantel-2decb/logs/ocsgw" +logName="projects/GCP_PROJECT_ID/logs/ocsgw" ``` \ No newline at end of file diff --git a/docs/NEO4J.md b/docs/NEO4J.md index 881d7ba3c..621c5876d 100644 --- a/docs/NEO4J.md +++ b/docs/NEO4J.md @@ -9,17 +9,21 @@ This is a temporary solution till we have a proper means of setup: Using `:sysinfo` command, check the roles for the cluster nodes.
For a 3-node Core cluster, one node is `LEADER` and 2 nodes are `FOLLOWER`s.
Only `LEADER` has _read/write_ access, whereas `FOLLOWER` has _read-only_ access.
-Choose appropriate node instead of _neo4j-core-2_ based on intent. +Choose appropriate node instead of _neo4j-neo4j-core-0_ based on intent. ### Set neo4j -> localhost entry in your `/etc/hosts` * On your developer machine, to `/etc/hosts` file, add `neo4j` entry pointing to `localhost`. Your `/etc/hosts` should have this line. ```text -127.0.0.1 localhost neo4j neo4j-core-2.neo4j.default.svc.cluster.local +127.0.0.1 localhost neo4j + +127.0.0.1 neo4j-neo4j-core-0.neo4j-neo4j.neo4j.svc.cluster.local +# 127.0.0.1 neo4j-neo4j-core-1.neo4j-neo4j.neo4j.svc.cluster.local +# 127.0.0.1 neo4j-neo4j-core-2.neo4j-neo4j.neo4j.svc.cluster.local ``` -This is assuming `neo4j-core-2` is `LEADER` in _Neo4j Casual Cluster_. +This is assuming `neo4j-neo4j-core-0` is `LEADER` in _Neo4j Casual Cluster_. ### Set proper cluster in `kubectl` config @@ -30,21 +34,23 @@ Check your current cluster. kubectl config get-contexts ``` -If name of the cluster, where neo4j is deployed, is `private-cluster`, then change `kubectl config`. +change `kubectl config` to the cluster where neo4j is deployed. ```bash -kubectl config use-context $(kubectl config get-contexts --output name | grep private-cluster) +kubectl config use-context $(kubectl config get-contexts --output name | grep ostelco) ``` ### Port forward from neo4j pods +Assuming `neo4j` is running in `neo4j` k8s namespace, + ```bash -kubectl get pods +kubectl get pods -n neo4j ``` Choose one of the neo4j pod from the list. ```bash -kubectl port-forward neo4j-core-2 7474:7474 7687:7687 +kubectl port-forward -n neo4j neo4j-neo4j-core-0 7474:7474 7687:7687 ``` Here, `neo4j browser` web-app is exposed over port `7474`. diff --git a/docs/TEST.md b/docs/TEST.md index aa448c9e0..d7abc5c5a 100644 --- a/docs/TEST.md +++ b/docs/TEST.md @@ -2,40 +2,65 @@ ### Setup - * Configure firebase project - `pantel-2decb` + * Configure firebase project - `GCP_PROJECT_ID` - * Save `pantel-prod.json` in all folders where this file is added in `.gitignore`. You can find these directories by + * Save `prime-service-account.json` in all folders where this file is added in `.gitignore`. You can find these directories by executing the command: ```bash -grep -i pantel $(find . -name '.gitignore') | awk -F: '{print $1}' | sort | uniq | sed 's/.gitignore//g' +grep -i prime-service-account $(find . -name '.gitignore') | awk -F: '{print $1}' | sort | uniq | sed 's/.gitignore//g' ``` * Create self-signed certificate for nginx with domain as `ocs.dev.ostelco.org` and place them at following location: * In `certs/ocs.dev.ostelco.org`, keep `nginx.key` and `nginx.cert`. - * In `ocsgw/config`, keep `ocs.cert`. + * In `ocsgw/cert`, keep `ocs.cert`. ```bash cd certs/ocs.dev.ostelco.org openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./nginx.key -out ./nginx.crt -subj '/CN=ocs.dev.ostelco.org' -cp nginx.crt ../../ocsgw/config/ocs.crt +cp nginx.crt ../../ocsgw/cert/ocs.crt ``` * Create self-signed certificate for nginx with domain as `metrics.dev.ostelco.org` and place them at following location: * In `certs/metrics.dev.ostelco.org`, keep `nginx.key` and `nginx.cert`. - * In `ocsgw/config`, keep `metrics.cert`. + * In `ocsgw/cert`, keep `metrics.cert`. ```bash cd certs/metrics.dev.ostelco.org openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./nginx.key -out ./nginx.crt -subj '/CN=metrics.dev.ostelco.org' -cp nginx.crt ../../ocsgw/config/metrics.crt +cp nginx.crt ../../ocsgw/cert/metrics.crt ``` - * Set Stripe API key as env variable - `STRIPE_API_KEY` + * Set Stripe API key as env variable - `STRIPE_API_KEY`. Note: It is the key denoted as "Secret key" that shuld + be set in this env variable. + + * Set Stripe Enpoint Secret (for Stripe events) as env variable - `STRIPE_ENDPOINT_SECRET` + + If Stripe events is not enabled the `STRIPE_ENDPOINT_SECRET` env variable can be set to a random string. + + To enable Stripe events: + + 1. Configure Stripe by adding Primes endpoint for Stripe events to the Stripe event configuration (webhook). + 2. Set the `STRIPE_ENDPOINT_SECRET` to the secret generated by Stripe. + + For dev the current configured endpoint for Stripe events is: + + https://alvin-api.dev.ostelco.org/stripe/events + + Endpoint for prod will be added later. - ### Test acceptance-tests ```bash -gradle clean build +cd acceptance-tests +gradlew clean build docker-compose up --build --abort-on-container-exit ``` + +#### Verify Scan information data + +Acceptance tests will create few encrypted zip files, these can be verified by running the `__testDecryption()` method in `ScanInfostore.kt`. +- Download encrypted files created in the root folder of prime docker image. +- Find files by logging into the docker image `docker exec -ti prime bash`. +- Copy files from docker image using `docker cp prime:/global_f1a6a509-7998-405c-b186-08983c91b422 .` +- Replace the path for the input files in the method & run. It will create a `decrypted.zip` output file. +- Manually decompress and verify the contents. diff --git a/docs/domain-model/classes.png b/docs/domain-model/classes.png deleted file mode 100644 index 1e7ff0ab2..000000000 Binary files a/docs/domain-model/classes.png and /dev/null differ diff --git a/docs/domain-model/classes.puml b/docs/domain-model/classes.puml index ef2fe5fde..7242090ef 100644 --- a/docs/domain-model/classes.puml +++ b/docs/domain-model/classes.puml @@ -1,82 +1,77 @@ @startuml - -class Simcard { - + ICCID - + IMSI +class Region { + + regionCode: String + + regionName: String } -note left: Navigable - -class Subscription { - + MSISDN +class CustomerRegion { + + status: CustomerRegionStatus + + kycStatusMap: [KycType, KycStatus] } -note left: Navigable - -class ProductClass { - + id: UUID - + path: String +class Customer { + + customerId: UUID + + nickname: String + + contactEmail: Email + + analyticsId: UUID + + referralId: UUID +-- + getAvailableProducts():[ProductID] } -note left: Navigable -class ParameterType { - + name - + type +class Bundle { + + balance: Long } - -class ParameterInstance { - + name - + value +class SimProfile { + + ICCID: String + + status: String + + alias: String } -class Price { -+ currency -+ amount +class Subscription { + + MSISDN: String } +class Segment +note left: Visible to Admin Only -class Product { - + SKU: UUID - -- - getPresentation():Presentation -} -note left: Navigable +class Offer +note left: Visible to Admin Only -class Validity { - + start: Timestamp - + end: Timestamp +class ProductClass { + + id: UUID + + path: String } -class Subscriber { --- - getAvailableProducts():[ProductID] +class Price { + + currency: String + + amount: Int } -note left: Navigable -class Offer{ +class Product { + + SKU: UUID + + price: Price } -note left: Navigable - - - - - -Simcard "*" -- "1" Subscription -Subscription "1" -- "1" Subscriber -Subscriber "1" -- "*" Purchase -Subscriber "*" -- "*" Segment -Segment "1" -- "*" Offer -Purchase "1" -- "*" Product +Customer "1" -- "*" Customer +Customer "1" -- "*" CustomerRegion +Region "1" -- "*" CustomerRegion +Customer "1" -- "*" Bundle +SimProfile "*" -- "1" Region +SimProfile "1" -- "*" Subscription +Subscription "*" -- "1" Customer +Subscription "*" -- "*" Bundle + +Customer "*" -- "*" Segment +Segment "*" -- "*" Offer +Offer "*" -- "*" Product +Product "1" -- "1" Price +Product "*" -- "1" ProductClass + +Purchase "*" -- "1" Product +Customer "1" -- "*" Purchase Purchase "1" -- "1" Payment -Offer "1" -- Validity -Offer "1" -- Presentation -Offer -- "1" Product -Product -- "1" Price -Product -- "1" ProductClass -ProductClass "1" -- "*" ParameterType -Product -- "*" ParameterInstance -ParameterType "1" -- "1" ParameterInstance + @enduml diff --git a/docs/domain-model/classes.svg b/docs/domain-model/classes.svg new file mode 100644 index 000000000..d3c810693 --- /dev/null +++ b/docs/domain-model/classes.svg @@ -0,0 +1,90 @@ +RegionregionCode: StringregionName: StringCustomerRegionstatus: CustomerRegionStatuskycStatusMap: [KycType, KycStatus]CustomercustomerId: UUIDnickname: StringcontactEmail: EmailanalyticsId: UUIDreferralId: UUIDgetAvailableProducts():[ProductID]Bundlebalance: LongSimProfileICCID: Stringstatus: Stringalias: StringSubscriptionMSISDN: StringSegmentVisible to Admin OnlyOfferVisible to Admin OnlyProductClassid: UUIDpath: StringPricecurrency: Stringamount: IntProductSKU: UUIDprice: PricePurchasePayment1*1*1*1**11**1********11*1*11*11 \ No newline at end of file diff --git a/docs/domain-model/generate-diagrams.sh b/docs/domain-model/generate-diagrams.sh index 74ab9439d..2704921d6 100755 --- a/docs/domain-model/generate-diagrams.sh +++ b/docs/domain-model/generate-diagrams.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -plantuml classes.puml +plantuml -tsvg classes.puml diff --git a/docs/prod-deployment/deployment.png b/docs/prod-deployment/deployment.png deleted file mode 100644 index 3043a50ff..000000000 Binary files a/docs/prod-deployment/deployment.png and /dev/null differ diff --git a/docs/prod-deployment/deployment.puml b/docs/prod-deployment/deployment.puml index 85f63af7b..096554e65 100644 --- a/docs/prod-deployment/deployment.puml +++ b/docs/prod-deployment/deployment.puml @@ -7,11 +7,11 @@ package "Handset" { package "Payment service" { - [Payment] + [Payment] } package "Authentication service" { - [AUTH] + [AUTH] } @@ -20,24 +20,29 @@ package "Host Operator" { [PGW] } -package "On Premise (MVNE)" { - [Ocsgw] +package "GCP - Compute Engine" { + [OCSGW] } -package "GCP" { +package "GCP - Kubernetes" { - package "Cloud endpoints"{ - [CEP1] - [CEP2] - } + package "Cloud endpoints"{ + [CEP1] + [CEP2] + } + + package "PubSub"{ + [CCR] + } + + database DB - database DB node "Pod" { - [ESP1] - [ESP2] - [Prime] - } + [ESP1] + [ESP2] + [Prime] + } } [App] -- [AUTH] @@ -49,10 +54,12 @@ package "GCP" { [CEP2] - [ESP2]: gRPC -[PGW] - [Ocsgw]: diameter -[Ocsgw] - [CEP2] : gRPC +[PGW] - [OCSGW]: diameter +[OCSGW] - [CEP2] : gRPC [ESP1] - [Prime] : http [ESP2] - [Prime] : gRPC [Prime] - DB +[OCSGW] - [CCR] : ccr-topic +[CCR] - [Prime] : ccr-sub @enduml diff --git a/docs/prod-deployment/deployment.svg b/docs/prod-deployment/deployment.svg new file mode 100644 index 000000000..3f08d103b --- /dev/null +++ b/docs/prod-deployment/deployment.svg @@ -0,0 +1,78 @@ +HandsetPayment serviceAuthentication serviceHost OperatorGCP - Compute EngineGCP - KubernetesCloud endpointsPubSubPodAppMobile dataPaymentAUTHPGWOCSGWDBCEP1CEP2CCRESP1ESP2PrimegtphttpshttpgRPCdiametergRPChttpgRPCccr-topicccr-sub \ No newline at end of file diff --git a/docs/prod-deployment/generate-diagrams.sh b/docs/prod-deployment/generate-diagrams.sh index 7783a1095..56e35ea20 100755 --- a/docs/prod-deployment/generate-diagrams.sh +++ b/docs/prod-deployment/generate-diagrams.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -plantuml deployment.puml +plantuml -tsvg deployment.puml diff --git a/docs/test-deployment/deployment.png b/docs/test-deployment/deployment.png deleted file mode 100644 index 4e2d59309..000000000 Binary files a/docs/test-deployment/deployment.png and /dev/null differ diff --git a/docs/test-deployment/deployment.svg b/docs/test-deployment/deployment.svg new file mode 100644 index 000000000..18a7cab59 --- /dev/null +++ b/docs/test-deployment/deployment.svg @@ -0,0 +1,29 @@ +DockerAcceptance Test RunnerocsgwESPPrimeDBdiameterhttp @ 8080gRPCgRPC @ 8082 \ No newline at end of file diff --git a/docs/test-deployment/generate-diagrams.sh b/docs/test-deployment/generate-diagrams.sh index 7783a1095..56e35ea20 100755 --- a/docs/test-deployment/generate-diagrams.sh +++ b/docs/test-deployment/generate-diagrams.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -plantuml deployment.puml +plantuml -tsvg deployment.puml diff --git a/docs/workflow/generate-diagrams.sh b/docs/workflow/generate-diagrams.sh index 6d9e242ca..77b6b3543 100755 --- a/docs/workflow/generate-diagrams.sh +++ b/docs/workflow/generate-diagrams.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -plantuml workflow.puml +plantuml -tsvg workflow.puml diff --git a/docs/workflow/workflow.png b/docs/workflow/workflow.png deleted file mode 100644 index a6b85f177..000000000 Binary files a/docs/workflow/workflow.png and /dev/null differ diff --git a/docs/workflow/workflow.svg b/docs/workflow/workflow.svg new file mode 100644 index 000000000..2b6f25512 --- /dev/null +++ b/docs/workflow/workflow.svg @@ -0,0 +1,26 @@ +Make feature branchfeature/something from developAdd features until satisfiedCreate pull requesttowards 'develop' branchAmend until staticchecks in github indicatePR looks okMake a request for someone to review the codePass code reviewMerge into developPush snapshot releases (optional)Merge into master (optional)Promote selected snapshots into major releases \ No newline at end of file diff --git a/ekyc/build.gradle b/ekyc/build.gradle new file mode 100644 index 000000000..aac279443 --- /dev/null +++ b/ekyc/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "java-library" +} + +dependencies { + + implementation project(":prime-modules") + + // implementation project(":myinfo-client") + + implementation("io.jsonwebtoken:jjwt:$jjwtVersion") { + exclude group: "com.fasterxml.jackson.core", module:"jackson-databind" + } + + implementation "org.apache.cxf:cxf-rt-rs-security-jose:$cxfVersion" + + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation project(":ext-myinfo-emulator") + + testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5Version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5Version" +} + +test { + + if (project.hasProperty("mandrillApiKey")) { + environment("MANDRILL_API_KEY", mandrillApiKey) + } + + // native support to Junit5 in Gradle 4.6+ + useJUnitPlatform { + includeEngines 'junit-jupiter' + } + testLogging { + exceptionFormat = 'full' + events "PASSED", "FAILED", "SKIPPED" + } +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/KycModule.kt b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/KycModule.kt new file mode 100644 index 000000000..6ff0fe6dd --- /dev/null +++ b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/KycModule.kt @@ -0,0 +1,42 @@ +package org.ostelco.prime.ekyc + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonTypeName +import io.dropwizard.setup.Environment +import org.apache.http.client.HttpClient +import org.apache.http.impl.client.HttpClientBuilder +import org.ostelco.prime.ekyc.Registry.myInfoClient +import org.ostelco.prime.module.PrimeModule + +@JsonTypeName("kyc") +class KycModule : PrimeModule { + + @JsonProperty + fun setConfig(config: Config) { + ConfigRegistry.config = config + } + + override fun init(env: Environment) { + // TODO change this to Dropwizard's HttpClientBuilder with appropriate timeout values + myInfoClient = HttpClientBuilder.create().build() + } +} + +data class Config( + val myInfoApiUri: String, + val myInfoApiClientId: String, + val myInfoApiClientSecret: String, + val myInfoApiEnableSecurity: Boolean = true, + val myInfoApiRealm: String, + val myInfoRedirectUri: String, + val myInfoServerPublicKey: String, + val myInfoClientPrivateKey: String, + val myInfoPersonDataAttributes: String = "name,sex,dob,residentialstatus,nationality,mobileno,email,regadd") + +object ConfigRegistry { + lateinit var config: Config +} + +object Registry { + lateinit var myInfoClient: HttpClient; +} \ No newline at end of file diff --git a/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/dave/DaveValidator.kt b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/dave/DaveValidator.kt new file mode 100644 index 000000000..1eb5a1f11 --- /dev/null +++ b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/dave/DaveValidator.kt @@ -0,0 +1,42 @@ +package org.ostelco.prime.ekyc.dave + +import org.ostelco.prime.ekyc.DaveKycService + +private val seed = arrayOf(0, 2, 7, 6, 5, 4, 3, 2) +private val stCheckSums = arrayOf('J', 'Z', 'I', 'H', 'G', 'F', 'E', 'D', 'C', 'B', 'A') +private val fgCheckSums = arrayOf('X', 'W', 'U', 'T', 'R', 'Q', 'P', 'N', 'M', 'L', 'K') + +// Ref: https://gist.github.com/eddiemoore/7131781 +// Ref: https://samliew.com/singapore-nric-validator +class DaveValidator : DaveKycService { + + override fun validate(id: String?): Boolean { + + val idString = id?.trim()?.toUpperCase() ?: return false + + if (idString.length != 9) { + return false + } + + val weight = Array(8) { index -> + when (index) { + in 1..7 -> Integer.valueOf("${idString[index]}") * seed[index] + else -> 0 + } + }.sum() + + val offset = if (idString[0] == 'T' || idString[0] == 'G') { + 4 + } else { + 0 + } + + val checkSumIndex = (weight + offset) % 11 + + return idString[8] == when { + (idString[0] == 'S' || idString[0] == 'T') -> stCheckSums + (idString[0] == 'F' || idString[0] == 'G') -> fgCheckSums + else -> return false + }[checkSumIndex] + } +} \ No newline at end of file diff --git a/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/ExtendedCompressionCodecResolver.kt b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/ExtendedCompressionCodecResolver.kt new file mode 100644 index 000000000..93abf0a46 --- /dev/null +++ b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/ExtendedCompressionCodecResolver.kt @@ -0,0 +1,20 @@ +package org.ostelco.prime.ekyc.myinfo + +import io.jsonwebtoken.CompressionCodec +import io.jsonwebtoken.Header +import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver + +/** + * To handle `NONE` value for `zip` header in JWT. + */ +object ExtendedCompressionCodecResolver : DefaultCompressionCodecResolver() { + + override fun resolveCompressionCodec(header: Header<*>?): CompressionCodec? { + + if (header?.getCompressionAlgorithm() == "NONE") { + return null + } + + return super.resolveCompressionCodec(header) + } +} \ No newline at end of file diff --git a/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/Model.kt b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/Model.kt new file mode 100644 index 000000000..c0b867c7d --- /dev/null +++ b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/Model.kt @@ -0,0 +1,23 @@ +package org.ostelco.prime.ekyc.myinfo + +import com.fasterxml.jackson.annotation.JsonProperty + +data class TokenApiResponse( + @JvmField + @JsonProperty("access_token") + val accessToken: String, + + val scope: String, + + @JvmField + @JsonProperty("token_type") + val tokenType: String, + + @JvmField + @JsonProperty("expires_in") + val expiresIn: Long) + +enum class HttpMethod { + GET, + POST +} \ No newline at end of file diff --git a/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/MyInfoClient.kt b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/MyInfoClient.kt new file mode 100644 index 000000000..1d472ef9e --- /dev/null +++ b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/MyInfoClient.kt @@ -0,0 +1,213 @@ +package org.ostelco.prime.ekyc.myinfo + +import io.jsonwebtoken.Jwts +import org.apache.cxf.rs.security.jose.jwe.JweCompactConsumer +import org.apache.cxf.rs.security.jose.jwe.JweUtils +import org.apache.http.client.methods.HttpGet +import org.apache.http.client.methods.HttpPost +import org.apache.http.entity.StringEntity +import org.ostelco.prime.ekyc.ConfigRegistry.config +import org.ostelco.prime.ekyc.MyInfoKycService +import org.ostelco.prime.ekyc.Registry.myInfoClient +import org.ostelco.prime.ekyc.myinfo.HttpMethod.GET +import org.ostelco.prime.ekyc.myinfo.HttpMethod.POST +import org.ostelco.prime.getLogger +import org.ostelco.prime.jsonmapper.objectMapper +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.SecureRandom +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.time.Instant +import java.util.* +import javax.ws.rs.core.MediaType + +class MyInfoClient : MyInfoKycService by MyInfoClientSingleton + +object MyInfoClientSingleton : MyInfoKycService { + + private val logger by getLogger() + + override fun getPersonData(authorisationCode: String): String { + + // Call /token API to get access_token + val tokenApiResponse = getToken(authorisationCode = authorisationCode) + .let { content -> + objectMapper.readValue(content, TokenApiResponse::class.java) + } + + // extract uin_fin out of "subject" of claims of access_token + val claims = getClaims(tokenApiResponse.accessToken) + val uinFin = claims.body.subject + + // Using access_token and uin_fin, call /person API to get Person Data + return getPersonData( + uinFin = uinFin, + accessToken = tokenApiResponse.accessToken) + } + + private fun getToken(authorisationCode: String): String = + sendSignedRequest( + httpMethod = POST, + path = "/token", + queryParams = mapOf( + "grant_type" to "authorization_code", + "code" to authorisationCode, + "redirect_uri" to config.myInfoRedirectUri, + "client_id" to config.myInfoApiClientId, + "client_secret" to config.myInfoApiClientSecret)) + + + private fun getClaims(accessToken: String) = Jwts.parser() + .setCompressionCodecResolver(ExtendedCompressionCodecResolver) + .setSigningKey(KeyFactory + .getInstance("RSA") + .generatePublic(X509EncodedKeySpec(Base64 + .getDecoder() + .decode(config.myInfoServerPublicKey)))) + .parseClaimsJws(accessToken) + + + private fun getPersonData(uinFin: String, accessToken: String): String = + sendSignedRequest( + httpMethod = GET, + path = "/person/$uinFin", + queryParams = mapOf( + "client_id" to config.myInfoApiClientId, + "attributes" to config.myInfoPersonDataAttributes), + accessToken = accessToken) + + /** + * Ref: https://www.ndi-api.gov.sg/library/trusted-data/myinfo/tutorial3 + */ + private fun sendSignedRequest( + httpMethod: HttpMethod, + path: String, + queryParams: Map, + accessToken: String? = null): String { + + val queryParamsString = queryParams.entries.joinToString("&") { """${it.key}=${URLEncoder.encode(it.value, StandardCharsets.US_ASCII)}""" } + + val requestUrl = "${config.myInfoApiUri}$path" + + // Create HTTP request + val request = when (httpMethod) { + GET -> HttpGet("$requestUrl?$queryParamsString") + POST -> HttpPost(requestUrl).also { + it.entity = StringEntity(queryParamsString) + } + } + + if (config.myInfoApiEnableSecurity) { + + val nonce = SecureRandom.getInstance("SHA1PRNG").nextLong() + val timestamp = Instant.now().toEpochMilli() + + // A) Construct the Authorisation Token Parameter + val defaultAuthHeaders = mapOf( + "apex_l2_eg_timestamp" to "$timestamp", + "apex_l2_eg_nonce" to "$nonce", + "apex_l2_eg_app_id" to config.myInfoApiClientId, + "apex_l2_eg_signature_method" to "SHA256withRSA", + "apex_l2_eg_version" to "1.0") + + // B) Forming the Base String + // Base String is a representation of the entire request (ensures message integrity) + + val baseStringParams = defaultAuthHeaders + queryParams + + // i) Normalize request parameters + val baseParamString = baseStringParams.entries + .sortedBy { it.key } + .joinToString("&") { "${it.key}=${it.value}" } + + // ii) construct request URL ---> url is passed in to this function + // NOTE: need to include the ".e." in order for the security authorisation header to work + //myinfosgstg.api.gov.sg -> myinfosgstg.e.api.gov.sg + + val url = "${config.myInfoApiUri.toLowerCase().replace(".api.gov.sg", ".e.api.gov.sg")}$path" + + // iii) concatenate request elements (HTTP method + url + base string parameters) + val baseString = "$httpMethod&$url&$baseParamString" + + // C) Signing Base String to get Digital Signature + // Load pem file containing the x509 cert & private key & sign the base string with it to produce the Digital Signature + val signature = Signature.getInstance("SHA256withRSA") + .also { sign -> + sign.initSign(KeyFactory + .getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec( + Base64.getDecoder().decode(config.myInfoClientPrivateKey)))) + } + .also { sign -> sign.update(baseString.toByteArray()) } + .let(Signature::sign) + .let(Base64.getEncoder()::encodeToString) + + // D) Assembling the Authorization Header + + val authHeaders = mapOf("realm" to config.myInfoApiRealm) + + defaultAuthHeaders + + mapOf("apex_l2_eg_signature" to signature) + + var authHeaderString = "apex_l2_eg " + + authHeaders.entries + .joinToString(",") { """${it.key}="${it.value}"""" } + + if (accessToken != null) { + authHeaderString = "$authHeaderString,Bearer $accessToken" + } + + request.addHeader("Authorization", authHeaderString) + + } else if (accessToken != null) { + request.addHeader("Authorization", "Bearer $accessToken") + } + + request.addHeader("Cache-Control", "no-cache") + request.addHeader("Accept", MediaType.APPLICATION_JSON) + + if (httpMethod == POST) { + request.addHeader("Content-Type", MediaType.APPLICATION_FORM_URLENCODED) + } + + val response = myInfoClient.execute(request).also { + if (it.statusLine.statusCode != 200) { + logger.info("response: $httpMethod status: ${it.statusLine}") + } + } + + val content = response + ?.entity + ?.content + ?.readAllBytes() + ?.let { String(it) } + ?.also { + logger.info("$httpMethod Response content: $it") + } + ?: "" + + if (config.myInfoApiEnableSecurity && httpMethod == GET) { + return decodeJweCompact(content) + } + + return content + } + + internal fun decodeJweCompact(jwePayload: String): String { + + val privateKey = KeyFactory + .getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec( + Base64.getDecoder().decode(config.myInfoClientPrivateKey))) + + val jweHeaders = JweCompactConsumer(jwePayload).jweHeaders + + return String(JweUtils.decrypt( + privateKey, + jweHeaders.keyEncryptionAlgorithm, + jweHeaders.contentEncryptionAlgorithm, + jwePayload)) + } +} diff --git a/pseudonym-server/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable b/ekyc/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable similarity index 100% rename from pseudonym-server/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable rename to ekyc/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable diff --git a/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.ekyc.DaveKycService b/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.ekyc.DaveKycService new file mode 100644 index 000000000..2950ab624 --- /dev/null +++ b/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.ekyc.DaveKycService @@ -0,0 +1 @@ +org.ostelco.prime.ekyc.dave.DaveValidator \ No newline at end of file diff --git a/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.ekyc.MyInfoKycService b/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.ekyc.MyInfoKycService new file mode 100644 index 000000000..756c059b9 --- /dev/null +++ b/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.ekyc.MyInfoKycService @@ -0,0 +1 @@ +org.ostelco.prime.ekyc.myinfo.MyInfoClient \ No newline at end of file diff --git a/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule new file mode 100644 index 000000000..a5c91d1d3 --- /dev/null +++ b/ekyc/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule @@ -0,0 +1 @@ +org.ostelco.prime.ekyc.KycModule \ No newline at end of file diff --git a/ekyc/src/test/kotlin/org/ostelco/prime/ekyc/myinfo/MyInfoClientTest.kt b/ekyc/src/test/kotlin/org/ostelco/prime/ekyc/myinfo/MyInfoClientTest.kt new file mode 100644 index 000000000..2dd387fd8 --- /dev/null +++ b/ekyc/src/test/kotlin/org/ostelco/prime/ekyc/myinfo/MyInfoClientTest.kt @@ -0,0 +1,208 @@ +package org.ostelco.prime.ekyc.myinfo + +import io.dropwizard.testing.ConfigOverride +import io.dropwizard.testing.DropwizardTestSupport +import io.dropwizard.testing.ResourceHelpers +import org.apache.http.impl.client.HttpClientBuilder +import org.junit.AfterClass +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.ostelco.ext.myinfo.MyInfoEmulatorApp +import org.ostelco.ext.myinfo.MyInfoEmulatorConfig +import org.ostelco.prime.ekyc.Config +import org.ostelco.prime.ekyc.ConfigRegistry +import org.ostelco.prime.ekyc.Registry +import java.io.File +import java.io.FileInputStream +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.cert.CertificateFactory +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.util.* + +/** + * Ref: https://www.ndi-api.gov.sg/assets/lib/trusted-data/myinfo/specs/myinfo-kyc-v2.1.1.yaml.html#section/Environments + * + * https://{ENV_DOMAIN_NAME}/{VERSION}/{RESOURCE} + * + * ENV_DOMAIN_NAME: + * - Sandbox/Dev: https://myinfosgstg.api.gov.sg/dev/ + * - Staging: https://myinfosgstg.api.gov.sg/test/ + * - Production: https://myinfosg.api.gov.sg/ + * + * VERSION: `/v2` + */ +// Using certs from https://github.com/jamesleegovtech/myinfo-demo-app/tree/master/ssl +private val templateTestConfig = String(File("src/test/resources/stg-demoapp-client-privatekey-2018.pem").readBytes()) + .replace("\n","") + .removePrefix("-----BEGIN PRIVATE KEY-----") + .removeSuffix("-----END PRIVATE KEY-----") + .let { base64Encoded -> PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64Encoded)) } + .let { keySpec -> KeyFactory.getInstance("RSA").generatePrivate(keySpec) } + .let { clientPrivateKey: PrivateKey -> + Config( + myInfoApiUri = "https://myinfosgstg.api.gov.sg/test/v2", + myInfoApiClientId = "STG2-MYINFO-SELF-TEST", + myInfoApiClientSecret = "44d953c796cccebcec9bdc826852857ab412fbe2", + myInfoRedirectUri = "http://localhost:3001/callback", + myInfoApiRealm = "http://localhost:3001", + myInfoPersonDataAttributes = "name,sex,race,nationality,dob,email,mobileno,regadd,housingtype,hdbtype,marital,edulevel,assessableincome,ownerprivate,assessyear,cpfcontributions,cpfbalances", + myInfoServerPublicKey = "", + myInfoClientPrivateKey = Base64.getEncoder().encodeToString(clientPrivateKey.encoded)) + } + +class MyInfoClientTest { + + @Before + fun setupUnitTest() { + ConfigRegistry.config = templateTestConfig.copy( + myInfoApiUri = "http://localhost:8080", + myInfoServerPublicKey = Base64.getEncoder().encodeToString(myInfoServerKeyPair.public.encoded)) + + Registry.myInfoClient = HttpClientBuilder.create().build() + } + + /** + * This test to send request to real staging server of MyInfo API. + * + * Some setup is needed to run this test. + * + * 1. Checkout the forked repo: https://github.com/ostelco/myinfo-demo-app + * 2. npm install + * 3. ./start.sh + * 4. Open web browser with Developer console open. + * 5. Goto http://localhost:3001 + * 6. Click button "RETRIEVE INFO" + * 7. Login to SingPass using username: S9812381D and password: MyInfo2o15 + * 8. Click on "Accept" button. + * 9. Copy authorisationCode from developer console of web browser. + * 10. Use this value in `test myInfo client` test. + * 11. Set @Before annotation on 'setupRealStaging()' instead of 'setupUnitTest()'. + * + */ + // @Before + fun setupRealStaging() { + // Using certs from https://github.com/jamesleegovtech/myinfo-demo-app/tree/master/ssl + // server public key + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificate= certificateFactory.generateCertificate(FileInputStream("src/test/resources/stg-auth-signing-public.pem")) + + ConfigRegistry.config = templateTestConfig.copy( + myInfoServerPublicKey = Base64.getEncoder().encodeToString(certificate.publicKey.encoded)) + + Registry.myInfoClient = HttpClientBuilder.create().build() + } + + @Test + fun `test myInfo client`() { + println(MyInfoClientSingleton.getPersonData(authorisationCode = "activation-code")) + } + + @Test + fun `decode person data`() { + val data = "eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAiLCJraWQiOiJlbmNyeXB0S2V5In0.nIc2yf-RKcnhKU4bJd-1XaSITQjurVt1ZDpGDeqiCH_XaV5mIBu2KLJRYKmo_ey6-XGL8pcm6nXynK0Y1CeWCUn3B8Pcn8Z-doJ0cIjXI8V_Dy5TyoyWCZB7tRgtMhpn93bCgsANx9lCRI6-TEYdu6U-sDki5VL6LgBJd8UeFCG9PHtKA9k3bGGQJSVRWPwq83iGe8XUhUAnZNarRgBMZe8IQbf78-YgeZw-bCzs_K4X1EOBW0ISJjj1yGts7N2uK2aQBLSw40UpWZdj_y2qwBtR5RKBMVXEH0zQBF7X9k_1diA047vFxQSgPS9M0Z46McoFmMku86SFxDvMZzE_Uw.6chXPc1zkWXeOvCg.pDKm8kbXcuatHt5iixt_FsuC87utIkG_Xec29WMv0vq9Y4JOflAwtBLcjuOIHV9ayPI70LVS7nGJMjHOcMe2jAH7airvMXB5Rg2rrGwRas_SIew_ZUWM_8-fLdlRL_KMaW2NTrsMGqKLdLhgfjd3PNTL5y9lWqCImaywEUzIH_2sFv0LrzZVH-f0q7lp9f4WjFCvc_8PGmuuMZhdxICVDvF-Ya1bivS7q_oEV6reStVuYhfIhqfuLNpBE_xHBrREd15jshxd3qIXHWbp9a5eBNO4CJ92Aqfyci5d7LBVdkf4vgVz6RVNfYaxOH1vljhMQq8oK9lv2jyZzwJzdod_YULKyCmwLJDjaXtTU4xjgksQkkQ9nkxUHh7LTijlgl0JJisatp_X4aiNfNqbbtwYCq7tZJewTy693F8s6wdEU-a0hJqMJWr3gkJ0OVwPAkiqI1lm9-T0ZHIJyGLBxOJJKLLAoOTbbW3GLt1QAr1wJKrSTTsIEBkx9UWcZmlyo0_3_yX_q0CJBlsXfN8YcX5ayzcPvSq-3Vaz-D5kkM1khbmSgBeIP4s9Vjq6Eqg_AHvPlb3qd-_Aq4N1lJ8oMPwyNMOPj8ZmDa8h8BpvuEUrBc9nw_yyTyBX21q0jAxqA5_SDYKfk35XRtqRo3c0_I_rp0nmYiVtdnsEVJuYowsCbV-UX6r-MmfojKA1vQaO-lcOUAW_BtVWRuRhkN6k6qmmEMJpxW2tbxTKN0dws_cMEaHZas0tZsBcsiFjFmLBGuexdxvpyTDiNdgiuNpcc1GCRPzRRcPtyCyRlDQLTEv4pFzmJ4Y1Gp85f7VeqebxzOkz7qudxF82eXHAO4vP-5d3pEn9YeQrGY0nU6NZrLRM2ghYzCuK9Dh-ifTwHaHnybQLY4n3n9BaKfv3q4Y3QExJIYk41zCnJP25VMH6aFFlW4XEWACttcHKOE9qCpQpWKnvsLIyXr9L4IFU9ePAamOnvD0Ce0SwA3jUXNNbfTO8otpL_JNLAJ8HQSvR7nYGt1PPJlRJp3b0ffAB_hQKZ5UL1mijTrG5f0eSiTo40ooPT-sqt_ZD0803khG4-KQqOw7J7fV7GX3QOOT-uvMgfnO-VpPAWihI16jH2Ak-1lJpXAB5rHskhpl2AL-L4Tjz24zBiWirzo81H57g3kkGWdgsyCdLeus-jKIR0-0Dnukgm5JvKFFqMg7MLPWB9iBWzNb0mPGkn5tA6yx1y_kCatKUvAS6sZSs5lQ6-ti-sxreOnwUoHiKt3AmZ2DyJgVccUef1dPFJdJF8bTfp1cASd6NwbdxROxsrPJPccU-a03UXGZJEGwHn9P81vH6G9wOd9N9a7XyjdH3Zu6pJBYo_MTzxfRzfBodEg9ECCtYPkkWWvPBetLgrzYS_htOvpwhB_-ZNpPtj_AihxRhaMHAiwR6-S1oBL6LWb4g6W8-vDIVO6b6mtrsoFO1mRv4y779JcKT66Tbv0WkPD4OU9VvInLr6NuxRSE2fihDrXv6QyPb5YypyccD60MXftJurllC8pCO61uHWvup4qQSaBtnGNxkpa3PuVwWMH5yzz_M4ls1kP0FzBpttJ6M49oBAHvAaGCg-WPacLf0A6yETOL2LRgTMD3UXL42LzLNf6VcgqD8f9EQIuzEz90K3L2HoNrYofx7K3j8uXMVXqqXUvAhKHD60g6z9d7S9qkgoZv_RNH2ykskBn5wAYiSgbB_vVfQu_3T-gdfut-z6U44_6D5zVFoxtvVGXZOros5qSHJC--LnmEXaHDcBPe2ZmjmOhoxoq_daiHOQjyD5c4dvJHLo4L-1bBsv54C60qDFGHPFetBqDuZ_C0UfBgOrMqPeYw9yWIWGHg3nUnZW3bPR8uuS_1bo1_cja8Iu5vIpvaoS9YG7hAai_T7bACi5vqmKzvbr3A19x2mhOcsVM7OWaY1ggDzFo7nrnyA0fZUSHQb80v3alTMZX4qYoOCHXVmVvzrpFgFuyJXGPUjev2dlJhkKJ8fzmPqLlqVFbA0toQmyLdrHDIb6njZSQGh207iBLVUH-DFktzpTEEEfUpIZ8cbitI8xCGmWetR6TGOaY8qs2_u5YQwzbS8eGZyy-2k0U7R8TOXv24U2athsCFDZf8eUfgV_82uE2cqdHSRV27y3SzbUaGVhTq2b4oQ1wzeRkW3mqhGh4vbepF3CT4d8b6Dgeoa9_NpLk3i9t3Osg0qx9VL5wq2N_avzWjW36URfe2h6ZDhEFCP17yFNZG4B7Pd5QQhAQy8eEFt8VaEGJFDwXKgz_UOgJt2X_IWWZMBOgjWI9ahaWDNyCjhmGC7s_8crq1lqBu7s8JzZW6SpcQVA7oFeyoBuSqbM4S1lf_aYBzSv8_1E9_1lfXQR_T3pGgAITP5xQr1-1--RgKLIRINGyssob3FeB_aO13-GS4R0svK9yvW7jHEEjRZgeZvmtPcSE_q-b0qX8xWbaR6FJMK0puyJxOdrBWtW8pglGRGdWY2k6KrFES-lRdtcjAubijqiQ1OmrWzxJbnK0gkpNj7oFlAFR6GMjvBHWRjTD8ykTv7FA_B_SOYiDTLlV76ghyXZchBtMdWL5l2ehil4dY7cvB10BJH7CyTmYTl7UNEjgwbLRvmVewKYkGrrOeFF9jZHKwPWkqDjB5e3kfM6Wp4KMgKKngXpwhB_4wO1M4S3uybUJqdQ0b7hznX_7mexbMF0dke-O0yynqqsghyvlqLboxZsfAYo6uU5Nvnt12SRJF5OmiWd2oY7RT-i-9rN-rL01tujuY6SCL2ytDo86vsu_eCg1ktV6uhUHdSNGiBHhHOl58tenFnkLGwbf_p2WeOFbYk0DGGru4QwUibY2cjVvzPHQU9po9BVf8aNd23ODLsPRJ9_AKeXjxU5G2v0mDho_7DfiPXukkHKu5qvIfGmDzKiYLbzTmx3VtPUWHDAVWHGsPjsFgprX1vGuFwDlRU2DNKJJUPBoxfcA1qvEwghifV3Nk5HGRePqk9V8Ebcd7nBHVc0g2xxccRtZmqtxsKPaGHCcMD350nkRalV_9qvHaPP3Z68TH_qrmG_3jnbG3v8eKEd65AUXvzbCVvg-FxYgKm__TCCPY0bsmjK2Lcyix9bjXQX7U3T4yWjKTeEtqkWlijadX_fEMO0xpcYLm8t-ektUMPRGz9d0ewSSMcE9IXnD8K_3BqFAQ1ZHOvxFKkDGdL3DYkK962F-resarButgCSPSFjzeC_YyVDLuTrWW7d40eDqAMrg5s-m3c_T_Fw0eqfRodot-lYokIVIOCSTjlDciTavr6NsRn7humEJA2YWP2qenxOvHRtpS_S6frXuIhixh1-XpyOKNs5kI4F2XFoLGHOKsb3S5K6mEpPpkpf9_dXWixURQ2oE5GN125p8jCkeCZYmVEX40wUHoY5A1Ucr5qjm_mYW1TLY3DU34wHVbvD90AgnXRxqYBc2NglU_KkMnC93NdRkSaYIb1qn4zu4VZ5oJHhiPC83ws7z9i-7AlrfjyyIWb6DhRH_PlcA7sEsaMyIZnpaFZl_ZNXyoDDEOu1zPYsmAsK-3KzUqGpD5NrMfAH8TYYDxM-hC14Y97HKJlnB5JrXM2rwnQpofsc3zXz5WQ2Sy7H2SGDWmuGPcyrg5T3ebf1a9AGAsCtZTomkxkMniJlpzOG5N5hyTIpYPF8pCJHzZ3F7XoewINnbd1FpyQwI4EU1TkVeJC9LX5AaCPGBtTLmcVj8IrbBlsAqy7Qm-LXK9uUuL2T20IuTo1zksgrSFbL6oLAUI1SsX6lss3XZsrZJwSqskXdArNoaZ_bWdzCJrTjeZ7JVXQbHCBbkT1WtsY27GakUOFTS82RwY4ut2lED6Jf7PRY1QfoXqqkNqZCLstpFLiVbdvW0J8JNbO6b9uMvd4U2b7DuSd_P36ZRQ1ynyrofishjDC0UpFlSUe5s8uN9-zUa8aWnhuzzBEi8eexthb8xY9KUy6WCJOwOUODKrF1novYLkHf572cgMr2FAVSfzTilTt06BSqAxqyXsBz8FpB5tOpERX_fP_t4ZPghzmF6WXelMtFYdU-UqhkHtSqM3e4JCK6OfgO-1St_7s3XxLh_VoiufL9GY2OHp1UGer_2JgcW-059RMLZ50FT3KU8lTtig43McyNxLBf9Ge7-depsomNj17ZcrepFMAMvEfOPhp3r0JKWaJRLCJdFqZZrSm1vHx07iSrdY4yrLArJYDho7i1utU01XahIz4qAeHr3q4JmQGLc_bdZYhN7e9LQ.kZHcvmdVfJzJnZJuQzsasA" + println(MyInfoClientSingleton.decodeJweCompact(data)) + } + + companion object { + + val myInfoServerKeyPair: KeyPair = KeyPairGenerator.getInstance("RSA") + .apply { this.initialize(2048) } + .genKeyPair() + + @JvmStatic + val SUPPORT: DropwizardTestSupport = CertificateFactory + .getInstance("X.509") + .generateCertificate(FileInputStream("src/test/resources/stg-demoapp-client-publiccert-2018.pem")) + .let { certificate -> + DropwizardTestSupport( + MyInfoEmulatorApp::class.java, + ResourceHelpers.resourceFilePath("myinfo-emulator-config.yaml"), + ConfigOverride.config("myInfoApiClientId", templateTestConfig.myInfoApiClientId), + ConfigOverride.config("myInfoApiClientSecret", templateTestConfig.myInfoApiClientSecret), + ConfigOverride.config("myInfoRedirectUri", templateTestConfig.myInfoRedirectUri), + ConfigOverride.config("myInfoServerPublicKey", Base64.getEncoder().encodeToString(myInfoServerKeyPair.public.encoded)), + ConfigOverride.config("myInfoServerPrivateKey", Base64.getEncoder().encodeToString(myInfoServerKeyPair.private.encoded)), + ConfigOverride.config("myInfoClientPublicKey", Base64.getEncoder().encodeToString(certificate.publicKey.encoded))) + } + + @JvmStatic + @BeforeClass + fun beforeClass() = SUPPORT.before() + + @JvmStatic + @AfterClass + fun afterClass() = SUPPORT.after() + } +} + +class RSAKeyTest { + + @Test + fun `test encode and decode`() { + val keyPair: KeyPair = KeyPairGenerator.getInstance("RSA") + .apply { this.initialize(2048) } + .genKeyPair() + + val encodedPublicKey = keyPair.public.encoded + + val base64PublicKey = Base64.getEncoder().encodeToString(encodedPublicKey) + val decodedPublicKey = Base64.getDecoder().decode(base64PublicKey) + + assertArrayEquals(encodedPublicKey, decodedPublicKey) + + assertEquals(keyPair.public, KeyFactory + .getInstance("RSA") + .generatePublic(X509EncodedKeySpec(Base64 + .getDecoder() + .decode(base64PublicKey)))) + + val encodedPrivateKey = keyPair.private.encoded + val base64PrivateKey = Base64.getEncoder().encodeToString(encodedPrivateKey) + val decodedPrivateKey = Base64.getDecoder().decode(base64PrivateKey) + + assertArrayEquals(encodedPrivateKey, decodedPrivateKey) + + assertEquals(keyPair.private, KeyFactory + .getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(Base64 + .getDecoder() + .decode(base64PrivateKey)))) + } + + @Test + fun `test loading MyInfo Staging Key`() { + + // Using public cert from https://github.com/jamesleegovtech/myinfo-demo-app/tree/master/ssl + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificate= certificateFactory.generateCertificate(FileInputStream("src/test/resources/stg-auth-signing-public.pem")) + certificate.publicKey + } + + @Test + fun `test loading MyInfo Staging client private key`() { + + // Using cert from https://github.com/jamesleegovtech/myinfo-demo-app/tree/master/ssl + val base64Encoded = String(File("src/test/resources/stg-demoapp-client-privatekey-2018.pem").readBytes()) + .replace("\n","") + .removePrefix("-----BEGIN PRIVATE KEY-----") + .removeSuffix("-----END PRIVATE KEY-----") + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64Encoded)) + val clientPrivateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec) + } + + @Test + fun `test MyInfo Staging client public key`() { + + // Using public cert from https://github.com/jamesleegovtech/myinfo-demo-app/tree/master/ssl + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificate= certificateFactory.generateCertificate(FileInputStream("src/test/resources/stg-demoapp-client-publiccert-2018.pem")) + certificate.publicKey + } +} \ No newline at end of file diff --git a/ekyc/src/test/resources/myinfo-emulator-config.yaml b/ekyc/src/test/resources/myinfo-emulator-config.yaml new file mode 100644 index 000000000..18bb874d7 --- /dev/null +++ b/ekyc/src/test/resources/myinfo-emulator-config.yaml @@ -0,0 +1,6 @@ +myInfoApiClientId: ${MYINFO_API_CLIENT_ID} +myInfoApiClientSecret: ${MYINFO_API_CLIENT_SECRET} +myInfoRedirectUri: ${MYINFO_REDIRECT_URI} +myInfoServerPublicKey: ${MYINFO_SERVER_PUBLIC_KEY} +myInfoServerPrivateKey: ${MYINFO_SERVER_PRIVATE_KEY} +myInfoClientPublicKey: ${MYINFO_CLIENT_PUBLIC_KEY} \ No newline at end of file diff --git a/ekyc/src/test/resources/stg-auth-signing-public.pem b/ekyc/src/test/resources/stg-auth-signing-public.pem new file mode 100755 index 000000000..c848b16a0 --- /dev/null +++ b/ekyc/src/test/resources/stg-auth-signing-public.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFPTCCBCWgAwIBAgIMKZm3WwAAAABJ0rAGMA0GCSqGSIb3DQEBCwUAME0xCzAJ +BgNVBAYTAlNHMSgwJgYDVQQKEx9OZXRydXN0IENlcnRpZmljYXRlIEF1dGhvcml0 +eSAxMRQwEgYDVQQLEwtOZXRydXN0IENBMTAeFw0xNzAyMDgwNzU0NDNaFw0yMDAy +MDgwODI0NDNaMIGlMQswCQYDVQQGEwJTRzEoMCYGA1UEChMfTmV0cnVzdCBDZXJ0 +aWZpY2F0ZSBBdXRob3JpdHkgMTEdMBsGA1UECxMUTmV0cnVzdCBDQTEgKFNlcnZl +cikxHDAaBgNVBAsTE01pbmlzdHJ5IG9mIEZpbmFuY2UxETAPBgNVBAsTCFNpbmdQ +YXNzMRwwGgYDVQQDExNzdGctYXV0aC5hcHAuZ292LnNnMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAshzrDr9ZQTqWWA2Defirgb8pnEkHFYLIvwG8Wh89 +XzATK6XmaJM2ZJj+T4B4zeETlw8onaVq7xPEcgSlblpFdP2G9402WneQ9PPyCsF/ +SBXLcUmcJ60r7H6IDN9+Hm0Cf4I3m4N/9SNsRRD3gBpQQE/Od3M6Ej4EA6njP1L/ +VgAW9M3Refz6jtBrQTqV4Jr7RzGU8m9WiZymbGYEeicBrK1yOPAcYcYLzVlmAvh3 +e3vt8HP3tpPIsE2mnO5XFyea7SB8dx++bT0qc4r5hIwyLCm0iNH+bmNLmMk7iTCz +49B6fPrnHcsKZ7mP+23DUTEYQub7Osu2DKFt3OnW584//QIDAQABo4IBwjCCAb4w +CwYDVR0PBAQDAgeAMBUGA1UdIAQOMAwwCgYIKoU+AIdqBgEwWAYJYIZIAYb6ax4B +BEsMSVRoZSBwcml2YXRlIGtleSBjb3JyZXNwb25kaW5nIHRvIHRoaXMgY2VydGlm +aWNhdGUgbWF5IGhhdmUgYmVlbiBleHBvcnRlZC4wgaoGA1UdHwSBojCBnzBmoGSg +YqRgMF4xCzAJBgNVBAYTAlNHMSgwJgYDVQQKEx9OZXRydXN0IENlcnRpZmljYXRl +IEF1dGhvcml0eSAxMRQwEgYDVQQLEwtOZXRydXN0IENBMTEPMA0GA1UEAxMGQ1JM +MTI3MDWgM6Axhi9odHRwOi8vbmV0cnVzdGNvbm5lY3Rvci5uZXRydXN0Lm5ldC9u +ZXRydXN0LmNybDArBgNVHRAEJDAigA8yMDE3MDIwODA3NTQ0M1qBDzIwMjAwMjA4 +MDgyNDQzWjAfBgNVHSMEGDAWgBQdRImyRSZ/b2uSxTp7cmPK0nAq3TAdBgNVHQ4E +FgQUGPUf1/TpG9z0RLitQNaiJ7LrNfYwCQYDVR0TBAIwADAZBgkqhkiG9n0HQQAE +DDAKGwRWOC4xAwIEsDANBgkqhkiG9w0BAQsFAAOCAQEAc1rcoqYFlwsUlhKkkm2q +DAoo+q0YsQQAIa+hl3/LebJEJ1bMe/9aduKRsD40nMol49kkyAKfztVrAeYzv9mq +mvSLo2P4EVWVROE19azQfbqK+GJTKbVvlMVfQkYTB97OqlqK7j6CzR2ocRHaxpQk +OASbWfuv84ImRBTs1jO0sJnqQQr6wC3iyf+zQQXA0uL05iyzxh9mYG5MduDX/HwN +yshfLfMVy8FQG+weKJMr5ewsON/YCCpxU3eQgdK9dIVeWPlCCaSVJXl9QIW0z3wE +QDDwGLY0hny8tkIGHdjrm+0Lo2rp5peVX02/FZ7lgfUqCW20v6CMRl6I/kKqSLUj +gw== +-----END CERTIFICATE----- diff --git a/ekyc/src/test/resources/stg-demoapp-client-privatekey-2018.pem b/ekyc/src/test/resources/stg-demoapp-client-privatekey-2018.pem new file mode 100644 index 000000000..061c8f229 --- /dev/null +++ b/ekyc/src/test/resources/stg-demoapp-client-privatekey-2018.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDGBRdsiDqKPGyH +gOpzxmSU2EQkm+zYZLvlPlwkwyfFWLndFLZ3saxJS+LIixsFhunrrUT9ZZ0x+bB6 +MV55o70z4ABOJRFNWx1wbMGqdiC0Fyfpwad3iYpRVjZO+5etHA9JEoaTPoFxv+kt +d8kVAL9P5I7/Pi6g1R+B2t2lsaE2bMSwtZqgs55gb7fsCR3Z4nQi7BddYR7MZ2lA +MWf7h7Dkm6uRlGhl2RvtmYa6dXFnK3RhIpdQOUT3quyhweMGspowC/tYSG+BNhy1 +WukbwhIP5vTAvv1WbHTg+WaUUV+pP0TjPQcY73clHxNpI5zrNqDmwD2rogNfePKR +UI63yBUfAgMBAAECggEAGy/7xVT25J/jLr+OcRLeIGmJAZW+8P7zpUfoksuQnFHQ +QwBjBRAJ3Y5jtrESprGdUFRb0oavDHuBtWUt2XmXspWgtRn1xC8sXZExDdxmJRPA +0SFbgtgJe51gm3uDmarullPK0lCUqS92Ll3x58ZQfgGdeIHrGP3p84Q/Rk6bGcOb +cPhDYWSOYKm4i2DPM01bnZG2z4BcrWSseOmeWUxqZcMlGz9GAyepUU/EoqRIHxw/ +2Y+TGus1JSy5DdhPE0HAEWKZH729ZdoyikOZCMxApQglUkRwkwhtXzVAemm6OSoy +3BEWvSEJh/F82tFrmquUoe/xd5JastlBHyD78RAakQKBgQDkHAzo1fowRI19tk7V +CPn0zMdF/UTRghtLywc/4xnw1Nd13m+orArOdVzPlQokLVNL81dIVKXnId0Hw/kX +8CRyRYz8tkL81spc39DfalZW7QI7Fschfq1Htgkxd/QEjBlIaqjkOjGSbX9xYjYU +1Db8PuGoGXWOsYiv9PCsKR056wKBgQDeOzfZSpV5kX8SECJXRA+emyCnO9S29p0W ++5BCTQp3OPnmbL7b/mGqBVJ0DC+IiN67Lu8xxzejswqLZqaRvmQuioqH+8mOGpXY +ZwhShAif2AuixxvL7OK6dvDmMqoKhBI9nZ9+XI60Cd/LjnWgyFO04uq4otnTukmY +sSP+fp6wnQKBgEopYH0WjFfDAelcKzcRywouxZ7Yn9Ypoaw7nujDcfydhktY/R5u +iLjk6T7H6tsmLU2lGLx4YNPLa6wJp+ODfKX2PMcwjojbYEFftu3cCaQLPE1vs2AN +alLFOSnvINOVpOapXq2Mye8cUHHRh1mwQQwzeXQIivLQf2sNjG28lDbvAoGACsh8 +0UJZNmjk7Y9y2yEmUN/eGb9Bdw9IWBEk0tLCKz7MgW3NZQdW3dUcRx1AQTPC+vow +CQ5NmNfbLyBv/KpsWgXG6wpAoXCQzMtTEA3wDTGCfweCRcbcyYdz8PeMYK4/5FV9 +o7gCBKJmBY6IDqEpzqEkGolsYGWtpIcT5Alo0dECgYEA3hzC9NLwumi/1JWm+ASS +ADTO3rrGo9hicG/WKGzSHD5l1f+IO1SfmUN/6i2JjcnE07eYArNrCfbMgkFavj50 +2ne2fSaYM4p0o147O9Ty8jCyY9vuh/ZGid6qUe3TBI6/okWfmYw6FVbRpNfVEeG7 +kPfkDW/JdH7qkWTFbh3eH1k= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/ekyc/src/test/resources/stg-demoapp-client-publiccert-2018.pem b/ekyc/src/test/resources/stg-demoapp-client-publiccert-2018.pem new file mode 100644 index 000000000..cc9221a80 --- /dev/null +++ b/ekyc/src/test/resources/stg-demoapp-client-publiccert-2018.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIG7zCCBNegAwIBAgIMR7sDLAAAAABXyVMZMA0GCSqGSIb3DQEBCwUAMGgxCzAJ +BgNVBAYTAlNHMRgwFgYDVQQKEw9OZXRydXN0IFB0ZSBMdGQxJjAkBgNVBAsTHU5l +dHJ1c3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MRcwFQYDVQQDEw5OZXRydXN0IENB +IDItMTAeFw0xODA3MTEwMDUxNTdaFw0yMTA3MjAxNjAwMDBaMIHOMQswCQYDVQQG +EwJTRzEYMBYGA1UEChMPTmV0cnVzdCBQdGUgTHRkMSYwJAYDVQQLEx1OZXRydXN0 +IENlcnRpZmljYXRlIEF1dGhvcml0eTEgMB4GA1UECxMXTmV0cnVzdCBDQSAyLTEg +KFNlcnZlcikxMzAxBgNVBAsTKlNtYXJ0IE5hdGlvbiBBbmQgRGlnaXRhbCBHb3Zl +cm5tZW50IE9mZmljZTERMA8GA1UECxMIU2luZ1Bhc3MxEzARBgNVBAMTCnN0Zy1t +eWluZm8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDGBRdsiDqKPGyH +gOpzxmSU2EQkm+zYZLvlPlwkwyfFWLndFLZ3saxJS+LIixsFhunrrUT9ZZ0x+bB6 +MV55o70z4ABOJRFNWx1wbMGqdiC0Fyfpwad3iYpRVjZO+5etHA9JEoaTPoFxv+kt +d8kVAL9P5I7/Pi6g1R+B2t2lsaE2bMSwtZqgs55gb7fsCR3Z4nQi7BddYR7MZ2lA +MWf7h7Dkm6uRlGhl2RvtmYa6dXFnK3RhIpdQOUT3quyhweMGspowC/tYSG+BNhy1 +WukbwhIP5vTAvv1WbHTg+WaUUV+pP0TjPQcY73clHxNpI5zrNqDmwD2rogNfePKR +UI63yBUfAgMBAAGjggIwMIICLDALBgNVHQ8EBAMCB4AwSAYDVR0gBEEwPzA9Bggq +hT4Ah2oGATAxMC8GCCsGAQUFBwIBFiNodHRwOi8vd3d3Lm5ldHJ1c3QubmV0L291 +cnByYWN0aWNlczBYBglghkgBhvprHgEESwxJVGhlIHByaXZhdGUga2V5IGNvcnJl +c3BvbmRpbmcgdG8gdGhpcyBjZXJ0aWZpY2F0ZSBtYXkgaGF2ZSBiZWVuIGV4cG9y +dGVkLjBDBggrBgEFBQcBAQQ3MDUwMwYIKwYBBQUHMAKGJ2h0dHA6Ly9haWEubmV0 +cnVzdC5uZXQvbmV0cnVzdGNhMi0xLmNlcjCBuwYDVR0fBIGzMIGwMC2gK6Aphido +dHRwOi8vY3JsLm5ldHJ1c3QubmV0L25ldHJ1c3RjYTItMS5jcmwwf6B9oHukeTB3 +MQswCQYDVQQGEwJTRzEYMBYGA1UEChMPTmV0cnVzdCBQdGUgTHRkMSYwJAYDVQQL +Ex1OZXRydXN0IENlcnRpZmljYXRlIEF1dGhvcml0eTEXMBUGA1UEAxMOTmV0cnVz +dCBDQSAyLTExDTALBgNVBAMTBENSTDIwKwYDVR0QBCQwIoAPMjAxODA3MTEwMDUx +NTdagQ8yMDIxMDcyMDE2MDAwMFowHwYDVR0jBBgwFoAUF0smS5R5Cl/fmvEIN8NI +N4O71/owHQYDVR0OBBYEFOiLrOLqn7QF+S7IsQLl7oUx7Nu0MAkGA1UdEwQCMAAw +DQYJKoZIhvcNAQELBQADggIBAI2PxvBz/5OIZsmEKDkkY2iWYDbRvQYHddmhC2Cj +CFeHcUFmuhp033gXyvdixhkqZ+icCxeAyeU0QJiVAuUWtaRWT3i8WdKGC9ePWLc8 +06TPCtEpnEavlb+iMq03GMq96pIM7MxQLQLeVl0ybjWqcknZDJ0zM6cZYrfOYnT8 +6e3w91Ze8ipvKQ8b3kBqSn1mphwiDY88AgSiRAzX/33QW20sYXZHclSpE4nKi12U +HsI+Ho2zlNdeJruMj8K1HO5t0Wo5AYeO5rgeJng1XizHKAlhDK+4bL3FmRcJx3DC +VliPKdqKKhkGqTT1CkZSNl+d/GViwJRKKnXJbuZf1gkz6k/72nuJbkx1KeBfyhVs +74JcAIE31xn7axXOSWuZBJAQ0akC264oMge7s3R9u8ipJeV9opY0Sj42lzc/fFa3 +Kq2kvIQB2q5b6ji+Xm1vE4uNOnc2BhQ+mK+6PlWQ2E4nYi5+h0JyRN/8Qg6F2l9J +CTK7jmiG3yQQ0a0OWJM8YrOuVeUMAHselLqLRLaLKi9dc7BFl5+zhj0FE8LpUUqP +yxXuLL6VABHb1gYab/vUfSgwS3QHcBdh6JsKJzPtOwzttWSWWvMK3Ahp2BhuNY6H +5SfdHtMUOpHQLKVNV83HlhCjOY51bufxTvv9t4JtuYy3Ojdquikp2PvnouKl8luC +meja +-----END CERTIFICATE----- \ No newline at end of file diff --git a/email-notifier/build.gradle b/email-notifier/build.gradle new file mode 100644 index 000000000..88dce81e1 --- /dev/null +++ b/email-notifier/build.gradle @@ -0,0 +1,37 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "java-library" +} + +dependencies { + + implementation project(":prime-modules") + + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + + implementation "com.google.zxing:core:$zxingVersion" + implementation "com.google.zxing:javase:$zxingVersion" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + + testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5Version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5Version" +} + +test { + + if (project.hasProperty("mandrillApiKey")) { + environment("MANDRILL_API_KEY", mandrillApiKey) + } + + // native support to Junit5 in Gradle 4.6+ + useJUnitPlatform { + includeEngines 'junit-jupiter' + } + testLogging { + exceptionFormat = 'full' + events "PASSED", "FAILED", "SKIPPED" + } +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/email-notifier/src/main/kotlin/org/ostelco/prime/notifications/email/EmailNotifierModule.kt b/email-notifier/src/main/kotlin/org/ostelco/prime/notifications/email/EmailNotifierModule.kt new file mode 100644 index 000000000..a3494abfb --- /dev/null +++ b/email-notifier/src/main/kotlin/org/ostelco/prime/notifications/email/EmailNotifierModule.kt @@ -0,0 +1,39 @@ +package org.ostelco.prime.notifications.email + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonTypeName +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.setup.Environment +import org.apache.http.client.HttpClient +import org.ostelco.prime.module.PrimeModule +import org.ostelco.prime.notifications.email.ConfigRegistry.config +import org.ostelco.prime.notifications.email.Registry.httpClient + +@JsonTypeName("email") +class EmailNotifierModule : PrimeModule { + + @JsonProperty + fun setConfig(config: Config) { + ConfigRegistry.config = config + } + + override fun init(env: Environment) { + httpClient = HttpClientBuilder(env) + .using(config.httpClientConfiguration) + .build("mandrill") + } +} + +data class Config( + val mandrillApiKey: String, + @JsonProperty("httpClient") + val httpClientConfiguration: HttpClientConfiguration = HttpClientConfiguration()) + +object ConfigRegistry { + lateinit var config: Config +} + +object Registry { + lateinit var httpClient: HttpClient +} \ No newline at end of file diff --git a/email-notifier/src/main/kotlin/org/ostelco/prime/notifications/email/MandrillClient.kt b/email-notifier/src/main/kotlin/org/ostelco/prime/notifications/email/MandrillClient.kt new file mode 100644 index 000000000..ea95fc1e5 --- /dev/null +++ b/email-notifier/src/main/kotlin/org/ostelco/prime/notifications/email/MandrillClient.kt @@ -0,0 +1,71 @@ +package org.ostelco.prime.notifications.email + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.zxing.BarcodeFormat +import com.google.zxing.client.j2se.MatrixToImageWriter +import com.google.zxing.qrcode.QRCodeWriter +import org.apache.http.client.entity.EntityBuilder +import org.apache.http.client.methods.HttpPost +import org.apache.http.entity.ContentType +import org.apache.http.util.EntityUtils +import org.ostelco.prime.getLogger +import org.ostelco.prime.notifications.EmailNotifier +import org.ostelco.prime.notifications.email.ConfigRegistry.config +import org.ostelco.prime.notifications.email.Registry.httpClient +import java.io.ByteArrayOutputStream +import java.util.* + +class MandrillClient : EmailNotifier by MandrillClientSingleton + +object MandrillClientSingleton : EmailNotifier { + + private val logger by getLogger() + + private const val API_URI = "/messages/send-template.json" + + private val qrCodeWriter = QRCodeWriter() + private val base64Encoder = Base64.getEncoder() + private val messageTemplate = this::class.java.getResource(API_URI).readText() + + override fun sendESimQrCodeEmail(email: String, name: String, qrCode: String): Either { + + val outputStream = ByteArrayOutputStream() + val bitMatrix = qrCodeWriter.encode(qrCode, BarcodeFormat.QR_CODE, 600, 600) + MatrixToImageWriter.writeToStream(bitMatrix, "png", outputStream) + val base64EncodedQRCode = base64Encoder.encodeToString(outputStream.toByteArray()) + + val reqBody = messageTemplate.setVariableData(mapOf( + "API_KEY" to config.mandrillApiKey, + "RECEIVER_EMAIL" to email, + "RECEIVER_NAME" to name, + "QR_CODE" to base64EncodedQRCode + )) + + val httpPost = HttpPost("https://mandrillapp.com/api/1.0$API_URI") + + httpPost.entity = EntityBuilder.create() + .setText(reqBody) + .setContentType(ContentType.APPLICATION_JSON) + .build() + + val response = httpClient.execute(httpPost) + + return if (response.statusLine.statusCode != 200) { + logger.error("Failed to send email") + logger.error(EntityUtils.toString(response.entity)) + Unit.left() + } else { + Unit.right() + } + } + + private fun String.setVariableData(variableDataMap: Map): String { + var result = this + variableDataMap.forEach { (variable, value) -> + result = result.replace(oldValue = "$$variable$", newValue = value, ignoreCase = false) + } + return result + } +} \ No newline at end of file diff --git a/email-notifier/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable b/email-notifier/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable new file mode 100644 index 000000000..8056fe23b --- /dev/null +++ b/email-notifier/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable @@ -0,0 +1 @@ +org.ostelco.prime.module.PrimeModule \ No newline at end of file diff --git a/email-notifier/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/email-notifier/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule new file mode 100644 index 000000000..fcf26156a --- /dev/null +++ b/email-notifier/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule @@ -0,0 +1 @@ +org.ostelco.prime.notifications.email.EmailNotifierModule \ No newline at end of file diff --git a/email-notifier/src/main/resources/META-INF/services/org.ostelco.prime.notifications.EmailNotifier b/email-notifier/src/main/resources/META-INF/services/org.ostelco.prime.notifications.EmailNotifier new file mode 100644 index 000000000..321a67e03 --- /dev/null +++ b/email-notifier/src/main/resources/META-INF/services/org.ostelco.prime.notifications.EmailNotifier @@ -0,0 +1 @@ +org.ostelco.prime.notifications.email.MandrillClient \ No newline at end of file diff --git a/email-notifier/src/main/resources/messages/send-template.json b/email-notifier/src/main/resources/messages/send-template.json new file mode 100644 index 000000000..64c907aec --- /dev/null +++ b/email-notifier/src/main/resources/messages/send-template.json @@ -0,0 +1,44 @@ +{ + "key": "$API_KEY$", + "template_name": "esim-qr-code", + "template_content": [ + { + "name": "customerName", + "content": "$RECEIVER_NAME$" + } + ], + "message": { + "to": [ + { + "email": "$RECEIVER_EMAIL$", + "name": "$RECEIVER_NAME$", + "type": "to" + } + ], + "important": true, + "track_opens": true, + "track_clicks": true, + "tags": [ + "esim-qr-code" + ], + "merge_vars": [ + { + "rcpt": "$RECEIVER_EMAIL$", + "vars": [ + { + "name": "customerName", + "content": "$RECEIVER_NAME$" + } + ] + } + ], + "images": [ + { + "type": "image/png", + "name": "esim_qr_code_image", + "content": "$QR_CODE$" + } + ] + }, + "async": false +} \ No newline at end of file diff --git a/email-notifier/src/main/resources/messages/send.json b/email-notifier/src/main/resources/messages/send.json new file mode 100644 index 000000000..02845364a --- /dev/null +++ b/email-notifier/src/main/resources/messages/send.json @@ -0,0 +1,30 @@ +{ + "key": "$API_KEY$", + "message": { + "html": "

Download OYA Data-only eSIM using this QR code

", + "subject": "Download OYA Data-only eSIM using this QR code", + "from_email": "welcome@oya.sg", + "from_name": "OYA", + "to": [ + { + "email": "$RECEIVER_EMAIL$", + "name": "$RECEIVER_NAME$", + "type": "to" + } + ], + "important": true, + "track_opens": true, + "track_clicks": true, + "tags": [ + "esim-qr-code" + ], + "images": [ + { + "type": "image/png", + "name": "eSIM QR code", + "content": "$QR_CODE$" + } + ] + }, + "async": false +} \ No newline at end of file diff --git a/email-notifier/src/test/kotlin/org/ostelco/prime/notifications/email/MandrillClientTest.kt b/email-notifier/src/test/kotlin/org/ostelco/prime/notifications/email/MandrillClientTest.kt new file mode 100644 index 000000000..983a4ae25 --- /dev/null +++ b/email-notifier/src/test/kotlin/org/ostelco/prime/notifications/email/MandrillClientTest.kt @@ -0,0 +1,51 @@ +package org.ostelco.prime.notifications.email + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.KotlinModule +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.configuration.EnvironmentVariableSubstitutor +import io.dropwizard.configuration.SubstitutingSourceProvider +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIf +import org.ostelco.prime.module.PrimeModule +import org.ostelco.prime.module.getResource +import org.ostelco.prime.notifications.EmailNotifier + +class TestApp : Application() { + + override fun initialize(bootstrap: Bootstrap) { + bootstrap.configurationSourceProvider = SubstitutingSourceProvider( + bootstrap.configurationSourceProvider, + EnvironmentVariableSubstitutor(false)) + bootstrap.objectMapper.registerModule(KotlinModule()) + } + + override fun run(configuration: TestConfig, environment: Environment) { + configuration.modules.forEach { it.init(environment) } + } +} + +class TestConfig: Configuration() { + + @JsonProperty + lateinit var modules: List +} + +class MandrillClientTest { + + @DisabledIf("systemEnvironment.get('MANDRILL_API_KEY') == null") + @Test + fun `send test email via Mandrill`() { + + TestApp().run("server", "src/test/resources/config.yaml") + + Thread.sleep(3000) + + val emailNotifier = getResource() + + emailNotifier.sendESimQrCodeEmail(email = "vihang@redotter.sg", name = "Vihang", qrCode = "Hello") + } +} \ No newline at end of file diff --git a/email-notifier/src/test/resources/config.yaml b/email-notifier/src/test/resources/config.yaml new file mode 100644 index 000000000..a37891fe9 --- /dev/null +++ b/email-notifier/src/test/resources/config.yaml @@ -0,0 +1,7 @@ +modules: +- type: email + config: + mandrillApiKey: ${MANDRILL_API_KEY} + httpClient: + timeout: 3s + connectionRequestTimeout: 1s \ No newline at end of file diff --git a/exporter/Dockerfile b/exporter/Dockerfile index 471f1ecb7..5e3d63534 100644 --- a/exporter/Dockerfile +++ b/exporter/Dockerfile @@ -1,6 +1,6 @@ FROM ubuntu:18.04 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" RUN apt-get update && apt-get install -y --no-install-recommends \ apt-utils \ diff --git a/exporter/README.md b/exporter/README.md index bd30f12b5..325464330 100644 --- a/exporter/README.md +++ b/exporter/README.md @@ -32,9 +32,9 @@ kubectl exec -it -- /bin/bash # Run exporter from the above shell /export_data.sh # This results in 3 csv files in GCS -1) Data consumption Records: gs://pantel-2decb-dataconsumption-export/.csv -2) Purchase Records: gs://pantel-2decb-dataconsumption-export/-purchases.csv -3) Subscriber to MSISDN mappings: gs://pantel-2decb-dataconsumption-export/-sub2msisdn.csv +1) Data consumption Records: gs://GCP_PROJECT_ID-dataconsumption-export/.csv +2) Purchase Records: gs://GCP_PROJECT_ID-dataconsumption-export/-purchases.csv +3) Subscriber to MSISDN mappings: gs://GCP_PROJECT_ID-dataconsumption-export/-sub2msisdn.csv # Run subsciber reverse lookup from the above shell /map_subscribers.sh diff --git a/exporter/deploy/deploy-dev.sh b/exporter/deploy/deploy-dev.sh index e2b4a6479..9a8997e8c 100755 --- a/exporter/deploy/deploy-dev.sh +++ b/exporter/deploy/deploy-dev.sh @@ -9,17 +9,17 @@ fi kubectl config use-context $(kubectl config get-contexts --output name | grep dev-cluster) -PROJECT_ID="$(gcloud config get-value project -q)" +GCP_PROJECT_ID="$(gcloud config get-value project -q)" SHORT_SHA="$(git log -1 --pretty=format:%h)" TAG="v${SHORT_SHA}-dev" -echo PROJECT_ID=${PROJECT_ID} +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} echo SHORT_SHA=${SHORT_SHA} echo TAG=${TAG} -docker build -t eu.gcr.io/${PROJECT_ID}/exporter:${TAG} exporter -docker push eu.gcr.io/${PROJECT_ID}/exporter:${TAG} +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/exporter:${TAG} exporter +docker push eu.gcr.io/${GCP_PROJECT_ID}/exporter:${TAG} echo "Deploying exporter to GKE" -sed -e s/EXPORTER_VERSION/${TAG}/g exporter/deploy/exporter-dev.yaml | kubectl apply -f - \ No newline at end of file +sed -e 's/EXPORTER_VERSION/'"${TAG}"'/g; s/_GCP_PROJECT_ID/'"${GCP_PROJECT_ID}"'/g' exporter/deploy/exporter-dev.yaml | kubectl apply -f - \ No newline at end of file diff --git a/exporter/deploy/deploy.sh b/exporter/deploy/deploy.sh index 4f0ef926a..f3d9cc6ad 100755 --- a/exporter/deploy/deploy.sh +++ b/exporter/deploy/deploy.sh @@ -9,17 +9,20 @@ fi kubectl config use-context $(kubectl config get-contexts --output name | grep private-cluster) -PROJECT_ID="$(gcloud config get-value project -q)" +GCP_PROJECT_ID="$(gcloud config get-value project -q)" SHORT_SHA="$(git log -1 --pretty=format:%h)" TAG="v${SHORT_SHA}" -echo PROJECT_ID=${PROJECT_ID} +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} echo SHORT_SHA=${SHORT_SHA} echo TAG=${TAG} -docker build -t eu.gcr.io/${PROJECT_ID}/exporter:${TAG} exporter -docker push eu.gcr.io/${PROJECT_ID}/exporter:${TAG} +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/exporter:${TAG} exporter +docker push eu.gcr.io/${GCP_PROJECT_ID}/exporter:${TAG} echo "Deploying exporter to GKE" -sed -e s/EXPORTER_VERSION/${TAG}/g exporter/deploy/exporter.yaml | kubectl apply -f - \ No newline at end of file +sed -e 's/EXPORTER_VERSION/'"${TAG}"'/g; s/_GCP_PROJECT_ID/'"${GCP_PROJECT_ID}"'/g' exporter/deploy/exporter.yaml | kubectl apply -f - + + +GCP_PROJECT_ID \ No newline at end of file diff --git a/exporter/deploy/exporter-dev.yaml b/exporter/deploy/exporter-dev.yaml index aba828f6d..175b1e339 100644 --- a/exporter/deploy/exporter-dev.yaml +++ b/exporter/deploy/exporter-dev.yaml @@ -15,11 +15,11 @@ spec: spec: containers: - name: exporter - image: eu.gcr.io/pantel-2decb/exporter:EXPORTER_VERSION + image: eu.gcr.io/_GCP_PROJECT_ID/exporter:EXPORTER_VERSION imagePullPolicy: Always env: - - name: PROJECT_ID - value: pantel-2decb + - name: GCP_PROJECT_ID + value: _GCP_PROJECT_ID - name: DATASET_MODIFIER value: _dev ports: diff --git a/exporter/deploy/exporter.yaml b/exporter/deploy/exporter.yaml index f05109d88..8c9ca46c2 100644 --- a/exporter/deploy/exporter.yaml +++ b/exporter/deploy/exporter.yaml @@ -15,10 +15,10 @@ spec: spec: containers: - name: exporter - image: eu.gcr.io/pantel-2decb/exporter:EXPORTER_VERSION + image: eu.gcr.io/_GCP_PROJECT_ID/exporter:EXPORTER_VERSION imagePullPolicy: Always env: - - name: PROJECT_ID - value: pantel-2decb + - name: GCP_PROJECT_ID + value: _GCP_PROJECT_ID ports: - containerPort: 8080 diff --git a/exporter/script/delete_export_data.sh b/exporter/script/delete_export_data.sh index 51513b941..62950d460 100644 --- a/exporter/script/delete_export_data.sh +++ b/exporter/script/delete_export_data.sh @@ -10,10 +10,11 @@ exportId=${exportId//-} exportId=${exportId,,} # Set the projectId -if [[ -z "${PROJECT_ID}" ]]; then - projectId=pantel-2decb +if [[ -z "${GCP_PROJECT_ID}" ]]; then + echo "Missing GCP_PROJECT_ID env var" + exit else - projectId="${PROJECT_ID}" + projectId="${GCP_PROJECT_ID}" fi msisdnPseudonymsTable=exported_pseudonyms.${exportId}_msisdn diff --git a/exporter/script/export_data.sh b/exporter/script/export_data.sh index 4844d864b..dba3dfbc1 100644 --- a/exporter/script/export_data.sh +++ b/exporter/script/export_data.sh @@ -9,10 +9,11 @@ exportId=${exportId//-} exportId=${exportId,,} # Set the projectId -if [[ -z "${PROJECT_ID}" ]]; then - projectId=pantel-2decb +if [[ -z "${GCP_PROJECT_ID}" ]]; then + echo "Missing GCP_PROJECT_ID env var" + exit else - projectId="${PROJECT_ID}" + projectId="${GCP_PROJECT_ID}" fi # Set the datasetModifier diff --git a/exporter/script/map_subscribers.sh b/exporter/script/map_subscribers.sh index 7d7d520f6..7aa4fdedb 100644 --- a/exporter/script/map_subscribers.sh +++ b/exporter/script/map_subscribers.sh @@ -49,10 +49,11 @@ fi exportId=${exportId//-} exportId=${exportId,,} # Set the projectId -if [[ -z "${PROJECT_ID}" ]]; then - projectId=pantel-2decb +if [[ -z "${GCP_PROJECT_ID}" ]]; then + echo "Missing GCP_PROJECT_ID env var" + exit else - projectId="${PROJECT_ID}" + projectId="${GCP_PROJECT_ID}" fi csvfile=$projectId-dataconsumption-export/${exportId}-resultsegment-pseudoanonymized.csv diff --git a/ext-auth-provider/Dockerfile b/ext-auth-provider/Dockerfile index a69b2b1a3..fef99f5b2 100644 --- a/ext-auth-provider/Dockerfile +++ b/ext-auth-provider/Dockerfile @@ -1,6 +1,6 @@ -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" COPY script/start.sh /start.sh diff --git a/ext-auth-provider/build.gradle b/ext-auth-provider/build.gradle index b90dfed3f..37476e739 100644 --- a/ext-auth-provider/build.gradle +++ b/ext-auth-provider/build.gradle @@ -1,17 +1,19 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "application" - id "com.github.johnrengelman.shadow" version "4.0.1" + id "com.github.johnrengelman.shadow" version "5.0.0" } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" - implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation ("io.jsonwebtoken:jjwt:$jjwtVersion") { + exclude group: "com.fasterxml.jackson.core", module:"jackson-databind" + } - implementation "javax.xml.bind:jaxb-api:$jaxbVersion" - implementation "javax.activation:activation:$javaxActivationVersion" + runtimeOnly "javax.xml.bind:jaxb-api:$jaxbVersion" + runtimeOnly "javax.activation:activation:$javaxActivationVersion" testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" @@ -23,4 +25,6 @@ shadowJar { mergeServiceFiles() classifier = "uber" version = null -} \ No newline at end of file +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/ext-auth-provider/src/main/kotlin/org/ostelco/ext/authprovider/AuthProviderApp.kt b/ext-auth-provider/src/main/kotlin/org/ostelco/ext/authprovider/AuthProviderApp.kt index 35b7f82f7..0c71bde02 100644 --- a/ext-auth-provider/src/main/kotlin/org/ostelco/ext/authprovider/AuthProviderApp.kt +++ b/ext-auth-provider/src/main/kotlin/org/ostelco/ext/authprovider/AuthProviderApp.kt @@ -13,9 +13,7 @@ import javax.ws.rs.core.Response internal const val JWT_SIGNING_KEY = "jwt_secret" -fun main(args: Array) { - AuthProviderApp().run("server") -} +fun main() = AuthProviderApp().run("server") class AuthProviderApp : Application() { diff --git a/ext-myinfo-emulator/Dockerfile b/ext-myinfo-emulator/Dockerfile new file mode 100644 index 000000000..f57e825f0 --- /dev/null +++ b/ext-myinfo-emulator/Dockerfile @@ -0,0 +1,13 @@ +FROM azul/zulu-openjdk:11.0.1-11.2 + +LABEL maintainer="dev@redotter.sg" + +COPY script/start.sh /start.sh + +COPY config/config.yaml /config/config.yaml + +COPY build/libs/ext-myinfo-emulator-uber.jar /ext-myinfo-emulator.jar + +EXPOSE 8080 + +CMD ["/start.sh"] \ No newline at end of file diff --git a/ext-myinfo-emulator/build.gradle b/ext-myinfo-emulator/build.gradle new file mode 100644 index 000000000..e723c0d89 --- /dev/null +++ b/ext-myinfo-emulator/build.gradle @@ -0,0 +1,31 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "application" + id "com.github.johnrengelman.shadow" version "5.0.0" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + // This is not a prime-module. Just needed access to getLogger and Dropwizard KotlinModule. + implementation project(":prime-modules") + + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + + implementation ("io.jsonwebtoken:jjwt:$jjwtVersion") { + exclude group: "com.fasterxml.jackson.core", module:"jackson-databind" + } + implementation "org.apache.cxf:cxf-rt-rs-security-jose:$cxfVersion" + + runtimeOnly "javax.xml.bind:jaxb-api:$jaxbVersion" + runtimeOnly "javax.activation:activation:$javaxActivationVersion" +} + +shadowJar { + mainClassName = 'org.ostelco.ext.myinfo.MyInfoEmulatorAppKt' + mergeServiceFiles() + classifier = "uber" + version = null +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/ext-myinfo-emulator/config/config.yaml b/ext-myinfo-emulator/config/config.yaml new file mode 100644 index 000000000..587f7eb36 --- /dev/null +++ b/ext-myinfo-emulator/config/config.yaml @@ -0,0 +1,6 @@ +myInfoApiClientId: STG2-MYINFO-SELF-TEST +myInfoApiClientSecret: 44d953c796cccebcec9bdc826852857ab412fbe2 +myInfoRedirectUri: http://localhost:3001/callback +myInfoServerPublicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqWWA2rH1wuBkd1zp0uOh+dnCRRcQWiI89ildk9UGSd3kgzPx1mYEL40cBOBVpSIkbRp65fJDjBm+MhzlHBgWZ1q27S30nczwnzAUJqUfJvLeCW7HLwqwPVSQlqby/n4MV2AKUu0jMacOeXE3Bevm92BEOH9wQhv81Rd7HZXRJGgMecqmVehMT7Mk88xHJvvWD1bYSQL5ADnNz1v0wq/afOVYPWAOl7xYoIgokYJQD3WwnKHVcotZcP8B5mu0AuMnP71JnzjVsRpwuO8N/m28fmzXCY7ARwRpz20Q6oOq09+ZMiJkpdT5TTqEF1u3FxTq5TY8CY60q9L5RqEUNJA9fQIDAQAB +myInfoServerPrivateKey: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpZYDasfXC4GR3XOnS46H52cJFFxBaIjz2KV2T1QZJ3eSDM/HWZgQvjRwE4FWlIiRtGnrl8kOMGb4yHOUcGBZnWrbtLfSdzPCfMBQmpR8m8t4JbscvCrA9VJCWpvL+fgxXYApS7SMxpw55cTcF6+b3YEQ4f3BCG/zVF3sdldEkaAx5yqZV6ExPsyTzzEcm+9YPVthJAvkAOc3PW/TCr9p85Vg9YA6XvFigiCiRglAPdbCcodVyi1lw/wHma7QC4yc/vUmfONWxGnC47w3+bbx+bNcJjsBHBGnPbRDqg6rT35kyImSl1PlNOoQXW7cXFOrlNjwJjrSr0vlGoRQ0kD19AgMBAAECggEAETbjdVYIZ5luHEMw7+F6IG3ktCi33LEGCO4DOHa5gOAvzLWGqgcL/dkVM9moWnOxx/Sc8KlB/AbRsT4GemhoPnCVjlzRMwgUm9jC83psRAUCU2sSitFHP7RMTUMCBqLAllCIPA0lOnKDogvDT7K9cf4ycJtiyRUXCICuXj9PVaei4GBwPemNqXYhpsqQ1dX/trCE+ssW0k82Glh5i/Ya7WVC90uvklWl2yOPAkNZJCL9o3ee6Romy0oOPhAWa0Yzpv7XG0Tnnq1xCCl41g+rfbnSI49oM7OtymqXJatzsxdd9nOWRK2hJJn5Kki3SPHWsYzDGie5w7cqZ2r7LtIKcQKBgQDZFUDA3z2Nao4r5AmW7NM7v5D/N8UJDzj/1k7m/AgIMExJtSvp0FSN4TCjY2l41U4FH55NF/GQowO6iFsky1pA6WpD0ffrxEO3og7N/dCcTKFxYsTCayrbYxbZz5kuSe8pslK2nAdHk2cpbZYmJtvJhnp2at2AEWaKEK0v6ENzZwKBgQDHw70Sk6hNTxa1OH80cKHa3Joxz0dobjv74qhZZ/TlE4xIA7hC/aI2CYzxqQJqY6nUwOyviIhCrQur6veh/9offNIYoeUDAMk0LzoA+zaDKITUTRdWw0D5qat9DZfeOnwA4WKkCLssfUfU4zQOB7RND0OC7rBwlrKeUFhYxi39ewKBgQCUauJtu3Ny50IyoeMoi9xTwkKZK4EME5tN1zD03aWEK7lMv5A5eJUGt8qwOryWv4drG1X4sYEX+UwIUUe3XUzkinF70udlCzedKjBjB8CDzCSox/VsUybm/dVWr4e0TRo18NR6Qyz6872ZZnI8vV6RZt79bUzpTXxEsuglk+/hywKBgDgV3J+6lSYfbmCy3AP9G3Q3O5OAfqvzRyQRHvL7HOaz9k7BvJoSW9iQFeJDcUotcSEqiUk/LAKMUxqRfbUeAD+W6+W+jm2patQb7k0YOtXYKnLwsfzKFRQDbwJNLrZV9TrKDMfBK8vx4JkEsTi/MP/xqSK5oQ/7P2rzY9qIyaDBAoGAK+M4Gn6FKWA8QbUcHqIL9NjSDUXGIY7yMjNk5Hd3tCIuHEAYjGVmFx1l9X5/bT6s0rNPINKxpHBJXp7Cpj1aw1Sbbo0GMYSuhS5lOx0YnNu4nd/EiOTZLwVMy7rWX+xA8HK7TxMar4nurWHYl7rtdMUQqE/eMaZnj9+dJvLKoKI= +myInfoClientPublicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxgUXbIg6ijxsh4Dqc8ZklNhEJJvs2GS75T5cJMMnxVi53RS2d7GsSUviyIsbBYbp661E/WWdMfmwejFeeaO9M+AATiURTVsdcGzBqnYgtBcn6cGnd4mKUVY2TvuXrRwPSRKGkz6Bcb/pLXfJFQC/T+SO/z4uoNUfgdrdpbGhNmzEsLWaoLOeYG+37Akd2eJ0IuwXXWEezGdpQDFn+4ew5JurkZRoZdkb7ZmGunVxZyt0YSKXUDlE96rsocHjBrKaMAv7WEhvgTYctVrpG8ISD+b0wL79Vmx04PlmlFFfqT9E4z0HGO93JR8TaSOc6zag5sA9q6IDX3jykVCOt8gVHwIDAQAB \ No newline at end of file diff --git a/ext-myinfo-emulator/docker-compose.yaml b/ext-myinfo-emulator/docker-compose.yaml new file mode 100644 index 000000000..f2bb79b43 --- /dev/null +++ b/ext-myinfo-emulator/docker-compose.yaml @@ -0,0 +1,16 @@ +version: "3.7" + +services: + ext-myinfo-emulator: + container_name: ext-myinfo-emulator + build: . + environment: + - MYINFO_API_CLIENT_ID=STG2-MYINFO-SELF-TEST + - MYINFO_API_CLIENT_SECRET=44d953c796cccebcec9bdc826852857ab412fbe2 + - MYINFO_REDIRECT_URI=http://localhost:3001/callback + - MYINFO_SERVER_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjyb5dUNbjySDcOVgGgrAwV+hIddk85lmygeFXfr+M13gXvleUphgWvyU5RQzztYRz9xVEDuOJvE6TzoCqHmmutpJr09HfXa7q9ihJup6FdEAKxLc2DfMk4YYSYM/Ar4ym4qyiaZV46Ih/6TXqeQDvUlLCjPGDzr4/rbTnkuBzSDRSeAttjaN0+xZT4aIeJv0hWGW1pwJB767rd3BrQrbmuEyoyy0yXC3BTdvWY9eUQmAzIc1EcrVRiNTsxv7PQkexAZ6K2IC8xZGngXDZk4kDxBoa9dr8Vt6RaPInQYJ8ZfGS2tuTRZMpxbt1cD5pwh4XZYm6lp273GGwgId/sU8KQIDAQAB + - MYINFO_SERVER_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCPJvl1Q1uPJINw5WAaCsDBX6Eh12TzmWbKB4Vd+v4zXeBe+V5SmGBa/JTlFDPO1hHP3FUQO44m8TpPOgKoeaa62kmvT0d9drur2KEm6noV0QArEtzYN8yThhhJgz8CvjKbirKJplXjoiH/pNep5AO9SUsKM8YPOvj+ttOeS4HNINFJ4C22No3T7FlPhoh4m/SFYZbWnAkHvrut3cGtCtua4TKjLLTJcLcFN29Zj15RCYDMhzURytVGI1OzG/s9CR7EBnorYgLzFkaeBcNmTiQPEGhr12vxW3pFo8idBgnxl8ZLa25NFkynFu3VwPmnCHhdlibqWnbvcYbCAh3+xTwpAgMBAAECggEAT7svegaYOnPl2b2H9zlB/vJadtTXX25zd9cUizG+37FLxNozlOFV2ZeQ/r5v1KYaqaFYp3/kPwBgr7vpuxh2qt/l5oTLlq/I+3/e0iQK95e+9j69TphVk10+rcMyFz8EROnNYymH8Q9ezFpt3CwpgQYPi5EmoVkBUi1WVHZNx4e/sib/vrlyyroAsLKTTDWuk6u2ObRtuTx7T55OwsI0sGmMj2HtXIrUBxGsZKnhgbcUxPCgz2TbOVUsKlVXPD/YrivMkGpD5EK+B2yP9Ur5wY+yA5bUSpV4I8GMEsK9YMeNAPekrXEeJmUGS/6N4lxdlfeuMGlFIRdFs9jVRVsmOQKBgQD/E4lxrJid916HwewVO7Hk6EKGMLoCKrCtYHKGu9lKxAkDAw4oOcE/U1Np/N0mslfvXoeUPDV8IlShmiwaOqxbhYjLakP0fA/kNZF/JOx29i1F69rAEja/m4mi6BpGc/hBL8GkDJkZVoOk2eUvEjnlsP8OHh5WqTyprjev0+YXRwKBgQCPq65C62jD8WRYjfWKAv3RqaUldByiUq5HOMY7e1IyEizqATjmXtfm/zUkBZ3u/is0H9itlZij31c01Dzxu1oa6RC/JmXbTl8PCFbtYB8SA9nk6f6NQXGaohEr4+shp6PwmsEFSEoP7dHTMfB0EpiSH/NK9NTr3kQwampap7SpDwKBgQDCEVUzHYQIO5q6YzqBdSeF54glnJEI8P33CzhXdjh+f+PzXLG6vSZgTb9bFj4UIQByaFNy3tQ6m8wUH76KPjXAdDp9uV32dyWIFZGbULZwVCBh4G5QUAtDgM0ZGspS7AznW7RSYhthgccq93U9ePp/3UhQr22Zta0n1BseNXQMJQKBgBa6AqvlT0JHqibz9dZqGLqUymH9VxY6XAU6Lulz3ZG8HEy7+sM+V4rb7g0Psmb+39iz/POgiW/KwaHCvQ1EJMHDAnoqWcxyPklDeXS9UsznvQ0gErtHke/zGSJHQIenXCCQal4qjESuyxVMfgvucSIUWckOp6vUEhdSjhZfFw3zAoGAIOnHp4U+Zle3OWvinavt9GjyAsb1TccnuMPXmoI4cTqb+XZcXIoENuvAsNfLmJKnfYgCYoUA7aAYn1znDs1FZNKg3bwx8TByvKA46sUi2x/isby6gWoiUGdpUfyxK/CFXG3uox7/XdHdy8TUGiGntua62cN1vRDsLiZI9fxda8g= + - MYINFO_CLIENT_PUBLIC_KEY= + ports: + - "8080:8080" + command: ["/bin/bash", "./start.sh"] \ No newline at end of file diff --git a/ext-myinfo-emulator/script/start.sh b/ext-myinfo-emulator/script/start.sh new file mode 100755 index 000000000..c5e86057f --- /dev/null +++ b/ext-myinfo-emulator/script/start.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# Start app +exec java \ + -Dfile.encoding=UTF-8 \ + -jar /ext-myinfo-emulator.jar server /config/config.yaml diff --git a/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/ExtendedCompressionCodecResolver.kt b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/ExtendedCompressionCodecResolver.kt new file mode 100644 index 000000000..f6a0cf546 --- /dev/null +++ b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/ExtendedCompressionCodecResolver.kt @@ -0,0 +1,20 @@ +package org.ostelco.ext.myinfo + +import io.jsonwebtoken.CompressionCodec +import io.jsonwebtoken.Header +import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver + +/** + * To handle `NONE` value for `zip` header in JWT. + */ +object ExtendedCompressionCodecResolver : DefaultCompressionCodecResolver() { + + override fun resolveCompressionCodec(header: Header<*>?): CompressionCodec? { + + if (header?.getCompressionAlgorithm() == "NONE") { + return null + } + + return super.resolveCompressionCodec(header) + } +} \ No newline at end of file diff --git a/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/JweCompactUtils.kt b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/JweCompactUtils.kt new file mode 100644 index 000000000..6ed157684 --- /dev/null +++ b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/JweCompactUtils.kt @@ -0,0 +1,25 @@ +package org.ostelco.ext.myinfo + +import org.apache.cxf.rs.security.jose.jwa.ContentAlgorithm.A256GCM +import org.apache.cxf.rs.security.jose.jwa.KeyAlgorithm.RSA_OAEP +import org.apache.cxf.rs.security.jose.jwe.JweUtils +import java.security.KeyFactory +import java.security.spec.X509EncodedKeySpec +import java.util.* + +object JweCompactUtils { + + fun encrypt(base64PublicKey: String, content: String): String { + + val publicKey = KeyFactory + .getInstance("RSA") + .generatePublic(X509EncodedKeySpec( + Base64.getDecoder().decode(base64PublicKey))) + + return JweUtils.encrypt( + publicKey, + RSA_OAEP, + A256GCM, + content.toByteArray()) + } +} \ No newline at end of file diff --git a/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/JwtUtils.kt b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/JwtUtils.kt new file mode 100644 index 000000000..e37ead7c2 --- /dev/null +++ b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/JwtUtils.kt @@ -0,0 +1,83 @@ +package org.ostelco.ext.myinfo + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm.RS256 +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.time.Instant +import java.util.* + +object JwtUtils { + + fun createAccessToken(signingPrivateKey: String): String { + + val now = Instant.now() + return Jwts.builder() + .setHeaderParam("typ", "JWT") + .setHeaderParam("zip", "NONE") + .setHeaderParam("kid", "AldO1lzsCInrpAQ2FF29oJsAGHk=") + .setPayload(""" + { + "sub": "S9812381D", + "auth_level": 0, + "auditTrackingId": "35a7f7ff-90cc-4368-9f00-26bba249629a", + "iss": "https://stg-home.singpass.gov.sg/consent/oauth2/consent/myinfo-com", + "tokenName": "access_token", + "token_type": "Bearer", + "authGrantId": "1659e065-7cdb-4d9e-af54-54e6b1fab953", + "aud": "myinfo", + "nbf": 1552124436, + "grant_type": "authorization_code", + "scope": [ + "name", + "sex", + "dob", + "residentialstatus", + "nationality", + "mobileno", + "email", + "regadd" + ], + "auth_time": 1552124419000, + "realm": "/consent/myinfo-com", + "exp": ${now.plusMillis(10000).toEpochMilli()}, + "iat": ${now.toEpochMilli()}, + "expires_in": 1800, + "jti": "be832d1c-21b7-4ba3-8c04-ec78a0928533" + } + """.trimIndent().replace("\n", "").replace(" ", "")) + .signWith(RS256, KeyFactory + .getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(signingPrivateKey)))) + .compact() + } + + fun getClaims(accessToken: String, signingPublicKey: String) = Jwts.parser() + .setCompressionCodecResolver(ExtendedCompressionCodecResolver) + .setSigningKey(KeyFactory + .getInstance("RSA") + .generatePublic(X509EncodedKeySpec(Base64 + .getDecoder() + .decode(signingPublicKey)))) + .parseClaimsJws(accessToken) +} + +fun main() { + val keyPair: KeyPair = KeyPairGenerator.getInstance("RSA") + .apply { this.initialize(2048) } + .genKeyPair() + + + println("-----BEGIN CERTIFICATE-----\n" + + "${Base64.getMimeEncoder( + 64, + System.lineSeparator().toByteArray()) + .encodeToString(keyPair.public.encoded)}\n" + + "-----END CERTIFICATE-----") + + println(Base64.getEncoder().encodeToString(keyPair.public.encoded)) + println(Base64.getEncoder().encodeToString(keyPair.private.encoded)) +} \ No newline at end of file diff --git a/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/MyInfoEmulatorApp.kt b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/MyInfoEmulatorApp.kt new file mode 100644 index 000000000..a643d7958 --- /dev/null +++ b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/MyInfoEmulatorApp.kt @@ -0,0 +1,45 @@ +package org.ostelco.ext.myinfo + +import com.fasterxml.jackson.module.kotlin.KotlinModule +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.configuration.EnvironmentVariableSubstitutor +import io.dropwizard.configuration.SubstitutingSourceProvider +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import org.glassfish.jersey.logging.LoggingFeature +import org.glassfish.jersey.logging.LoggingFeature.Verbosity.PAYLOAD_ANY +import java.util.logging.Logger + +fun main(args: Array) = MyInfoEmulatorApp().run(*args) + +class MyInfoEmulatorApp : Application() { + + override fun initialize(bootstrap: Bootstrap) { + bootstrap.configurationSourceProvider = SubstitutingSourceProvider( + bootstrap.configurationSourceProvider, + EnvironmentVariableSubstitutor(false)) + bootstrap.objectMapper.registerModule(KotlinModule()) + } + + override fun run( + config: MyInfoEmulatorConfig, + env: Environment) { + + + env.jersey().register(LoggingFeature( + Logger.getLogger(LoggingFeature.DEFAULT_LOGGER_NAME), + PAYLOAD_ANY)) + + env.jersey().register(TokenResource(config)) + env.jersey().register(PersonResource(config)) + } +} + +data class MyInfoEmulatorConfig( + val myInfoApiClientId: String, + val myInfoApiClientSecret: String, + val myInfoRedirectUri: String, + val myInfoServerPublicKey: String, + val myInfoServerPrivateKey: String, + val myInfoClientPublicKey: String) : Configuration() \ No newline at end of file diff --git a/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/Resources.kt b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/Resources.kt new file mode 100644 index 000000000..02d18ef6c --- /dev/null +++ b/ext-myinfo-emulator/src/main/kotlin/org/ostelco/ext/myinfo/Resources.kt @@ -0,0 +1,184 @@ +package org.ostelco.ext.myinfo + +import org.ostelco.ext.myinfo.JwtUtils.createAccessToken +import org.ostelco.ext.myinfo.JwtUtils.getClaims +import org.ostelco.prime.getLogger +import javax.ws.rs.Consumes +import javax.ws.rs.FormParam +import javax.ws.rs.GET +import javax.ws.rs.HeaderParam +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.PathParam +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.Context +import javax.ws.rs.core.HttpHeaders +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +@Path("/token") +class TokenResource(private val config: MyInfoEmulatorConfig) { + + private val logger by getLogger() + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + fun getToken( + @FormParam("grant_type") grantType: String?, + @FormParam("code") authorisationCode: String?, + @FormParam("redirect_uri") redirectUri: String?, + @FormParam("client_id") clientId: String?, + @FormParam("client_secret") clientSecret: String?, + @HeaderParam("Authorization") authHeaderString: String?, + @Context headers: HttpHeaders, + body: String): Response { + + logger.debug("Content-Type: ${headers.mediaType}") + logger.debug("Headers >>>\n${headers.requestHeaders.entries.joinToString("\n")}\n<<< End of Headers") + logger.debug("Body >>>\n$body\n<<< End of Body") + + return when { + + headers.mediaType != MediaType.APPLICATION_FORM_URLENCODED_TYPE -> + Response.status(Response.Status.BAD_REQUEST) + .entity("""{reason: "Invalid Content-Type - ${headers.mediaType}"}""") + .build() + + grantType != "authorization_code" -> + Response.status(Response.Status.BAD_REQUEST) + .entity("""{reason: "Invalid grant_type"}""") + .build() + + redirectUri != config.myInfoRedirectUri -> + Response.status(Response.Status.FORBIDDEN) + .entity("""{reason: "Invalid redirect_uri"}""") + .build() + + clientId != config.myInfoApiClientId -> + Response.status(Response.Status.FORBIDDEN) + .entity("""{reason: "Invalid client_id"}""") + .build() + + clientSecret != config.myInfoApiClientSecret -> + Response.status(Response.Status.FORBIDDEN) + .entity("""{reason: "Invalid client_secret"}""") + .build() + + else -> + Response.status(Response.Status.OK).entity(""" + { + "access_token":"${createAccessToken(config.myInfoServerPrivateKey)}", + "scope":"mobileno nationality dob name regadd email sex residentialstatus", + "token_type":"Bearer", + "expires_in":1799 + }""".trimIndent()) + .build() + } + } +} + +@Path("/person") +class PersonResource(private val config: MyInfoEmulatorConfig) { + + private val logger by getLogger() + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/{uinFin}") + fun getToken( + @PathParam("uinFin") uinFin: String, + @QueryParam("client_id") clientId: String, + @QueryParam("attributes") attributes: String, + @HeaderParam("Authorization") authHeaderString: String, + @Context headers: HttpHeaders, + body: String): Response { + + logger.debug("Content-Type: ${headers.mediaType}") + logger.debug("Headers >>>\n${headers.requestHeaders.entries.joinToString("\n")}\n<<< End of Headers") + logger.debug("Body >>>\n$body\n<<< End of Body") + + if (!authHeaderString.contains("Bearer ")) { + return Response.status(Response.Status.FORBIDDEN) + .entity("""{reason: "Missing JWT Access Token"}""") + .build() + } + + val claims = getClaims(authHeaderString.substringAfter("Bearer "), config.myInfoServerPublicKey) + + if (claims.body.subject != uinFin) { + return Response.status(Response.Status.FORBIDDEN) + .entity("""{reason: "Invalid Subject in Access Token"}""") + .build() + } + + if (authHeaderString.startsWith("Bearer ")) { + return Response + .status(Response.Status.OK) + .entity(getPersonData(uinFin = uinFin)) + .build() + } + + return Response + .status(Response.Status.OK) + .entity(JweCompactUtils.encrypt(config.myInfoClientPublicKey, getPersonData(uinFin = uinFin))) + .build() + } + + private fun getPersonData(uinFin: String): String = """ +{ + "name": { + "lastupdated": "2018-03-20", + "source": "1", + "classification": "C", + "value": "TAN XIAO HUI" + }, + "sex": { + "lastupdated": "2018-03-20", + "source": "1", + "classification": "C", + "value": "F" + }, + "nationality": { + "lastupdated": "2018-03-20", + "source": "1", + "classification": "C", + "value": "SG" + }, + "dob": { + "lastupdated": "2018-03-20", + "source": "1", + "classification": "C", + "value": "1970-05-17" + }, + "email": { + "lastupdated": "2018-08-23", + "source": "4", + "classification": "C", + "value": "myinfotesting@gmail.com" + }, + "mobileno": { + "lastupdated": "2018-08-23", + "code": "65", + "source": "4", + "classification": "C", + "prefix": "+", + "nbr": "97399245" + }, + "regadd": { + "country": "SG", + "unit": "128", + "street": "BEDOK NORTH AVENUE 4", + "lastupdated": "2018-03-20", + "block": "102", + "postal": "460102", + "source": "1", + "classification": "C", + "floor": "09", + "building": "PEARL GARDEN" + }, + "uinfin": "$uinFin" +} + """.trimIndent().replace("\n", "").replace(" ", "") +} \ No newline at end of file diff --git a/firebase-extensions/build.gradle b/firebase-extensions/build.gradle index 962e31d50..7669d276f 100644 --- a/firebase-extensions/build.gradle +++ b/firebase-extensions/build.gradle @@ -1,5 +1,5 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } diff --git a/firebase-extensions/src/main/kotlin/org.ostelco.common.firebasex/FirebaseExtensions.kt b/firebase-extensions/src/main/kotlin/org.ostelco.common.firebasex/FirebaseExtensions.kt index 43306213b..3846a4028 100644 --- a/firebase-extensions/src/main/kotlin/org.ostelco.common.firebasex/FirebaseExtensions.kt +++ b/firebase-extensions/src/main/kotlin/org.ostelco.common.firebasex/FirebaseExtensions.kt @@ -1,9 +1,9 @@ package org.ostelco.common.firebasex import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.google.auth.oauth2.GoogleCredentials import com.google.firebase.FirebaseOptions.Builder +import org.ostelco.prime.jsonmapper.objectMapper import java.io.File import java.io.FileInputStream import java.nio.file.Files @@ -11,7 +11,6 @@ import java.nio.file.Paths import javax.naming.ConfigurationException private val genericMapType = object : TypeReference>() {} -private val objectMapper = jacksonObjectMapper() /** * Extension function added into [com.google.firebase.FirebaseOptions.Builder] which accepts Firebase Credentials diff --git a/firebase-store/build.gradle b/firebase-store/build.gradle index 3c30ffebe..c668038bf 100644 --- a/firebase-store/build.gradle +++ b/firebase-store/build.gradle @@ -1,12 +1,9 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } dependencies { implementation project(":prime-modules") api project(":firebase-extensions") - - testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" } \ No newline at end of file diff --git a/firebase-store/scripts/create_balance.sh b/firebase-store/scripts/create_balance.sh index 5a2160d71..784675452 100755 --- a/firebase-store/scripts/create_balance.sh +++ b/firebase-store/scripts/create_balance.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash +GCP_PROJECT_ID=$(gcloud config get-value project -q) + for MSISDN in {178..190} do - echo firebase --project pantel-2decb --data '0' database:set /v2/balance/4790300${MSISDN} + echo firebase --project ${GCP_PROJECT_ID} --data '0' database:set /v2/balance/4790300${MSISDN} done diff --git a/firebase-store/scripts/create_subscriptions.sh b/firebase-store/scripts/create_subscriptions.sh index decf92cf1..53f86667a 100755 --- a/firebase-store/scripts/create_subscriptions.sh +++ b/firebase-store/scripts/create_subscriptions.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash +GCP_PROJECT_ID=$(gcloud config get-value project -q) export MSISDN= export EMAIL= export URL_ENCODED_EMAIL=$(echo "$EMAIL" | sed 's/\./%2E/g' | sed 's/@/%40/g') -echo firebase --project pantel-2decb --data "\"$MSISDN\"" database:set /v2/subscriptions/"$URL_ENCODED_EMAIL" +echo firebase --project ${GCP_PROJECT_ID} --data "\"$MSISDN\"" database:set /v2/subscriptions/"$URL_ENCODED_EMAIL" diff --git a/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseModule.kt b/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseModule.kt index a6b608dbc..7371260f9 100644 --- a/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseModule.kt +++ b/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseModule.kt @@ -17,4 +17,6 @@ object FirebaseConfigRegistry { lateinit var firebaseConfig: FirebaseConfig } -data class FirebaseConfig(val configFile: String, val rootPath: String) \ No newline at end of file +data class FirebaseConfig( + val configFile: String, + val rootPath: String) \ No newline at end of file diff --git a/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseStorage.kt b/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseStorage.kt index 4c651e8bd..994680467 100644 --- a/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseStorage.kt +++ b/firebase-store/src/main/kotlin/org/ostelco/prime/storage/firebase/FirebaseStorage.kt @@ -40,17 +40,17 @@ object FirebaseStorageSingleton : DocumentStore { return FirebaseDatabase.getInstance() } - override fun addNotificationToken(msisdn: String, token: ApplicationToken): Boolean { - return fcmTokenStore.set(token.applicationID, token) { databaseReference.child(urlEncode(msisdn)) } + override fun addNotificationToken(customerId: String, token: ApplicationToken): Boolean { + return fcmTokenStore.set(token.applicationID, token) { databaseReference.child(urlEncode(customerId)) } } - override fun getNotificationToken(msisdn: String, applicationID: String): ApplicationToken? { - return fcmTokenStore.get(applicationID) { databaseReference.child(urlEncode(msisdn)) } + override fun getNotificationToken(customerId: String, applicationID: String): ApplicationToken? { + return fcmTokenStore.get(applicationID) { databaseReference.child(urlEncode(customerId)) } } - override fun getNotificationTokens(msisdn: String): Collection { + override fun getNotificationTokens(customerId: String): Collection { return fcmTokenStore.getAll { - databaseReference.child(urlEncode(msisdn)) + databaseReference.child(urlEncode(customerId)) }.values } diff --git a/jacoco.gradle b/gradle/jacoco.gradle similarity index 74% rename from jacoco.gradle rename to gradle/jacoco.gradle index 9a294dd1c..1f506a14f 100644 --- a/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -1,7 +1,7 @@ jacocoTestReport { group = "Reporting" description = "Generate Jacoco coverage reports after running tests." - additionalSourceDirs = files(sourceSets.main.allJava.srcDirs) + getAdditionalSourceDirs().from(files(sourceSets.main.allJava.srcDirs)) reports { xml.enabled = true html.enabled = true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e0b3fb8d7..5f1b1201a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/graphql/build.gradle b/graphql/build.gradle new file mode 100644 index 000000000..41a65e41d --- /dev/null +++ b/graphql/build.gradle @@ -0,0 +1,16 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "java-library" +} + +dependencies { + implementation project(':prime-modules') + + implementation 'com.graphql-java:graphql-java:11.0' + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation "io.jsonwebtoken:jjwt:$jjwtVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/graphql/src/main/kotlin/org/ostelco/prime/graphql/DataFetchers.kt b/graphql/src/main/kotlin/org/ostelco/prime/graphql/DataFetchers.kt new file mode 100644 index 000000000..9e3a8d5ff --- /dev/null +++ b/graphql/src/main/kotlin/org/ostelco/prime/graphql/DataFetchers.kt @@ -0,0 +1,68 @@ +package org.ostelco.prime.graphql + +import com.fasterxml.jackson.core.type.TypeReference +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.Identity +import org.ostelco.prime.module.getResource +import org.ostelco.prime.storage.ClientDataSource + +val clientDataSource by lazy { getResource() } + + +class ContextDataFetcher : DataFetcher> { + + override fun get(env: DataFetchingEnvironment): Map? { + return env.getContext()?.let { identity -> + val map = mutableMapOf() + if (env.selectionSet.contains("customer/*")) { + clientDataSource.getCustomer(identity) + .map { customer -> + map.put("customer", objectMapper.convertValue(customer, object : TypeReference>() {})) + } + } + if (env.selectionSet.contains("bundles/*")) { + clientDataSource.getBundles(identity) + .map { bundles -> + map.put("bundles", bundles.map { bundle -> + objectMapper.convertValue>(bundle, object : TypeReference>() {}) + }) + } + } + if (env.selectionSet.contains("subscriptions/*")) { + clientDataSource.getSubscriptions(identity) + .map { subscriptions -> + map.put("subscriptions", subscriptions.map { subscription -> + objectMapper.convertValue>(subscription, object : TypeReference>() {}) + }) + } + } + if (env.selectionSet.contains("regions/*")) { + clientDataSource.getAllRegionDetails(identity) + .map { regions -> + map.put("regions", regions.map { region -> + objectMapper.convertValue>(region, object : TypeReference>() {}) + }) + } + } + if (env.selectionSet.contains("products/*")) { + clientDataSource.getProducts(identity) + .map { productsMap -> + map.put("products", productsMap.values.map { product -> + objectMapper.convertValue>(product, object : TypeReference>() {}) + }) + } + } + if (env.selectionSet.contains("purchases/*")) { + clientDataSource.getPurchaseRecords(identity) + .map { purchaseRecords -> + map.put("purchases", purchaseRecords.map { purchaseRecord -> + objectMapper.convertValue>(purchaseRecord, object : TypeReference>() {}) + }) + } + } + map + } + } +} \ No newline at end of file diff --git a/graphql/src/main/kotlin/org/ostelco/prime/graphql/GraphQLModule.kt b/graphql/src/main/kotlin/org/ostelco/prime/graphql/GraphQLModule.kt new file mode 100644 index 000000000..49c10c1e4 --- /dev/null +++ b/graphql/src/main/kotlin/org/ostelco/prime/graphql/GraphQLModule.kt @@ -0,0 +1,21 @@ +package org.ostelco.prime.graphql + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonTypeName +import io.dropwizard.setup.Environment +import org.ostelco.prime.module.PrimeModule +import java.io.File + +@JsonTypeName("graphql") +class GraphQLModule : PrimeModule { + + @JsonProperty + var config: Config = Config(schemaFile = "/config/customer.graphqls") + + override fun init(env: Environment) { + env.jersey().register( + GraphQLResource(QueryHandler(File(config.schemaFile)))) + } +} + +data class Config(val schemaFile: String) \ No newline at end of file diff --git a/graphql/src/main/kotlin/org/ostelco/prime/graphql/GraphQLResource.kt b/graphql/src/main/kotlin/org/ostelco/prime/graphql/GraphQLResource.kt new file mode 100644 index 000000000..e70f03067 --- /dev/null +++ b/graphql/src/main/kotlin/org/ostelco/prime/graphql/GraphQLResource.kt @@ -0,0 +1,67 @@ +package org.ostelco.prime.graphql + +import io.dropwizard.auth.Auth +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.jsonmapper.objectMapper +import org.ostelco.prime.model.Identity +import javax.ws.rs.Consumes +import javax.ws.rs.GET +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +@Path("/graphql") +class GraphQLResource(private val queryHandler: QueryHandler) { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + fun handlePost( + @Auth token: AccessTokenPrincipal?, + request: GraphQLRequest): Response { + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED).build() + } + + return executeOperation( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + request = request) + } + + @GET + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + fun handleGet( + @Auth token: AccessTokenPrincipal?, + @QueryParam("query") query: String): Response { + + if (token == null) { + return Response.status(Response.Status.UNAUTHORIZED).build() + } + + return executeOperation( + identity = Identity(id = token.name, type = "EMAIL", provider = token.provider), + request = GraphQLRequest(query = query)) + } + + private fun executeOperation(identity: Identity, request: GraphQLRequest): Response { + val executionResult = queryHandler.execute( + identity = identity, + query = request.query, + variables = request.variables) + val result = mutableMapOf() + if (executionResult.errors.isNotEmpty()) { + result["errors"] = executionResult.errors.map { it.message } + } + val data: Map? = executionResult.getData() + if (data != null) { + result["data"] = data + } + return Response.ok(asJson(objectMapper.convertValue(result, GraphQlResponse::class.java))).build() + } +} \ No newline at end of file diff --git a/graphql/src/main/kotlin/org/ostelco/prime/graphql/Model.kt b/graphql/src/main/kotlin/org/ostelco/prime/graphql/Model.kt new file mode 100644 index 000000000..f13025ba5 --- /dev/null +++ b/graphql/src/main/kotlin/org/ostelco/prime/graphql/Model.kt @@ -0,0 +1,23 @@ +package org.ostelco.prime.graphql + +import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Product +import org.ostelco.prime.model.PurchaseRecord +import org.ostelco.prime.model.Subscription + +data class GraphQLRequest( + val query: String, + val operationName: String? = null, + val variables: Map = emptyMap()) + +data class Context( + val customer: Customer? = null, + val bundles: Collection? = null, + val subscriptions: Collection? = null, + val products: Collection? = null, + val purchases: Collection? = null) + +data class Data(var context: Context? = null) + +data class GraphQlResponse(var data: Data? = null, var errors: List? = null) \ No newline at end of file diff --git a/graphql/src/main/kotlin/org/ostelco/prime/graphql/QueryHandler.kt b/graphql/src/main/kotlin/org/ostelco/prime/graphql/QueryHandler.kt new file mode 100644 index 000000000..ea336db1e --- /dev/null +++ b/graphql/src/main/kotlin/org/ostelco/prime/graphql/QueryHandler.kt @@ -0,0 +1,32 @@ +package org.ostelco.prime.graphql + +import graphql.ExecutionInput +import graphql.ExecutionResult +import graphql.GraphQL +import graphql.schema.idl.SchemaGenerator +import graphql.schema.idl.SchemaParser +import org.ostelco.prime.model.Identity +import java.io.File + + +class QueryHandler(schemaFile: File) { + + private val graphQL = SchemaGenerator() + .makeExecutableSchema( + SchemaParser().parse(schemaFile), + buildRuntimeWiring()) + .let { GraphQL.newGraphQL(it).build() } + + fun execute(identity: Identity, query: String, operationName: String? = null, variables: Map? = null): ExecutionResult{ + var executionInputBuilder = ExecutionInput.newExecutionInput() + .query(query) + .context(identity) + if (operationName != null) { + executionInputBuilder = executionInputBuilder.operationName(operationName) + } + if (variables != null) { + executionInputBuilder = executionInputBuilder.variables(variables) + } + return graphQL.execute(executionInputBuilder.build()) + } +} \ No newline at end of file diff --git a/graphql/src/main/kotlin/org/ostelco/prime/graphql/RuntimeWiring.kt b/graphql/src/main/kotlin/org/ostelco/prime/graphql/RuntimeWiring.kt new file mode 100644 index 000000000..1346e0a6d --- /dev/null +++ b/graphql/src/main/kotlin/org/ostelco/prime/graphql/RuntimeWiring.kt @@ -0,0 +1,11 @@ +package org.ostelco.prime.graphql + +import graphql.schema.idl.RuntimeWiring + +fun buildRuntimeWiring(): RuntimeWiring { + return RuntimeWiring.newRuntimeWiring() + .type("QueryType") { typeWiring -> + typeWiring.dataFetcher("context", ContextDataFetcher()) + } + .build() +} \ No newline at end of file diff --git a/graphql/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable b/graphql/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable new file mode 100644 index 000000000..8056fe23b --- /dev/null +++ b/graphql/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable @@ -0,0 +1 @@ +org.ostelco.prime.module.PrimeModule \ No newline at end of file diff --git a/graphql/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/graphql/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule new file mode 100644 index 000000000..f2ad2a833 --- /dev/null +++ b/graphql/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule @@ -0,0 +1 @@ +org.ostelco.prime.graphql.GraphQLModule \ No newline at end of file diff --git a/graphql/src/test/kotlin/org/ostelco/prime/graphql/GraphQLResourceTest.kt b/graphql/src/test/kotlin/org/ostelco/prime/graphql/GraphQLResourceTest.kt new file mode 100644 index 000000000..96da6ccb5 --- /dev/null +++ b/graphql/src/test/kotlin/org/ostelco/prime/graphql/GraphQLResourceTest.kt @@ -0,0 +1,74 @@ +package org.ostelco.prime.graphql + +import io.dropwizard.auth.AuthDynamicFeature +import io.dropwizard.auth.AuthValueFactoryProvider +import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter +import io.dropwizard.testing.junit.ResourceTestRule +import org.junit.Assert +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.graphql.util.AccessToken +import org.ostelco.prime.jsonmapper.objectMapper +import java.io.File +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.* +import javax.ws.rs.client.Entity +import javax.ws.rs.core.MediaType + +class GraphQLResourceTest { + + private val email = "graphql@test.com" + + @Before + fun setUp() { + `when`(AUTHENTICATOR.authenticate(ArgumentMatchers.anyString())) + .thenReturn(Optional.of(AccessTokenPrincipal(email, provider = "email"))) + } + + @Test + fun `test handlePost`() { + val resp = RULE.target("/graphql") + .request(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .post(Entity.json(GraphQLRequest(query = """{ context(id: "invalid@test.com") { customer { nickname, contactEmail } } }"""))) + .readEntity(GraphQlResponse::class.java) + + Assert.assertEquals(email, resp.data?.context?.customer?.contactEmail) + } + + @Test + fun `test handleGet`() { + val resp = RULE.target("/graphql") + .queryParam("query", URLEncoder.encode("""{context(id:"invalid@test.com"){customer{nickname,contactEmail}}}""", StandardCharsets.UTF_8.name())) + .request(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer ${AccessToken.withEmail(email)}") + .get(GraphQlResponse::class.java) + + Assert.assertEquals(email, resp.data?.context?.customer?.contactEmail) + } + + companion object { + + val AUTHENTICATOR = mock(OAuthAuthenticator::class.java) + + @JvmField + @ClassRule + val RULE: ResourceTestRule = ResourceTestRule.builder() + .setMapper(objectMapper) + .addResource(AuthDynamicFeature( + OAuthCredentialAuthFilter.Builder() + .setAuthenticator(AUTHENTICATOR) + .setPrefix("Bearer") + .buildAuthFilter())) + .addResource(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) + .addResource(GraphQLResource(QueryHandler(File("src/test/resources/customer.graphqls")))) + .build() + } +} \ No newline at end of file diff --git a/graphql/src/test/kotlin/org/ostelco/prime/graphql/MockStores.kt b/graphql/src/test/kotlin/org/ostelco/prime/graphql/MockStores.kt new file mode 100644 index 000000000..b19943f6e --- /dev/null +++ b/graphql/src/test/kotlin/org/ostelco/prime/graphql/MockStores.kt @@ -0,0 +1,38 @@ +package org.ostelco.prime.graphql + +import arrow.core.Either +import arrow.core.right +import org.mockito.Mockito +import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.Price +import org.ostelco.prime.model.Product +import org.ostelco.prime.model.PurchaseRecord +import org.ostelco.prime.model.Subscription +import org.ostelco.prime.storage.DocumentStore +import org.ostelco.prime.storage.GraphStore +import org.ostelco.prime.storage.StoreError + +class MockGraphStore : GraphStore by Mockito.mock(GraphStore::class.java) { + + private val product = Product(sku = "SKU", price = Price(amount = 10000, currency = "NOK")) + + override fun getCustomer(identity: Identity): Either = + Customer(id = identity.id, contactEmail = identity.id, nickname = "foo").right() + + override fun getBundles(identity: Identity): Either> = + listOf(Bundle(id = identity.id, balance = 1000000000L)).right() + + override fun getSubscriptions(identity: Identity, regionCode: String?): Either> = + listOf(Subscription(msisdn = "4790300123")).right() + + override fun getProducts(identity: Identity): Either> = + mapOf("SKU" to product).right() + + override fun getPurchaseRecords(identity: Identity): Either> = + listOf(PurchaseRecord(id = "PID", product = product, timestamp = 1234L)).right() + +} + +class MockDocumentStore : DocumentStore by Mockito.mock(DocumentStore::class.java) \ No newline at end of file diff --git a/graphql/src/test/kotlin/org/ostelco/prime/graphql/QueryHandlerTest.kt b/graphql/src/test/kotlin/org/ostelco/prime/graphql/QueryHandlerTest.kt new file mode 100644 index 000000000..e74837c50 --- /dev/null +++ b/graphql/src/test/kotlin/org/ostelco/prime/graphql/QueryHandlerTest.kt @@ -0,0 +1,41 @@ +package org.ostelco.prime.graphql + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.ostelco.prime.model.Identity +import java.io.File + +class QueryHandlerTest { + + private val queryHandler = QueryHandler(File("src/test/resources/customer.graphqls")) + + private val email = "foo@test.com" + + private fun execute(query: String): Map = queryHandler + .execute(identity = Identity(id = email, type = "EMAIL", provider = "email"), query = query) + .getData>() + + @Test + fun `test get profile`() { + val result = execute("""{ context(id: "invalid@test.com") { customer { nickname, contactEmail } } }""".trimIndent()) + assertEquals("{context={customer={nickname=foo, contactEmail=$email}}}", "$result") + } + + @Test + fun `test get bundles and products`() { + val result = execute("""{ context(id: "invalid@test.com") { bundles { id, balance } products { sku, price { amount, currency } } } }""".trimIndent()) + assertEquals("{context={bundles=[{id=$email, balance=1000000000}], products=[{sku=SKU, price={amount=10000, currency=NOK}}]}}", "$result") + } + + @Test + fun `test get subscriptions`() { + val result = execute("""{ context(id: "invalid@test.com") { subscriptions { msisdn } } }""".trimIndent()) + assertEquals("{context={subscriptions=[{msisdn=4790300123}]}}", "$result") + } + + @Test + fun `test get purchase history`() { + val result = execute("""{ context(id: "invalid@test.com") { purchases { id, product { sku, price { amount, currency } } } } }""".trimIndent()) + assertEquals("{context={purchases=[{id=PID, product={sku=SKU, price={amount=10000, currency=NOK}}}]}}", "$result") + } +} \ No newline at end of file diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/util/AccessToken.kt b/graphql/src/test/kotlin/org/ostelco/prime/graphql/util/AccessToken.kt similarity index 84% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/util/AccessToken.kt rename to graphql/src/test/kotlin/org/ostelco/prime/graphql/util/AccessToken.kt index 060ad4cbb..82f5449ac 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/util/AccessToken.kt +++ b/graphql/src/test/kotlin/org/ostelco/prime/graphql/util/AccessToken.kt @@ -1,4 +1,4 @@ -package org.ostelco.prime.client.api.util +package org.ostelco.prime.graphql.util import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm @@ -15,7 +15,7 @@ object AccessToken { return withEmail(email, audience) } - fun withEmail(email: String, audience: List): String { + private fun withEmail(email: String, audience: List): String { val claims = mapOf("$namespace/email" to email, "aud" to audience, @@ -26,4 +26,4 @@ object AccessToken { .signWith(SignatureAlgorithm.HS512, key) .compact() } -} +} \ No newline at end of file diff --git a/graphql/src/test/resources/META-INF/services/org.ostelco.prime.storage.DocumentStore b/graphql/src/test/resources/META-INF/services/org.ostelco.prime.storage.DocumentStore new file mode 100644 index 000000000..821f4d2e8 --- /dev/null +++ b/graphql/src/test/resources/META-INF/services/org.ostelco.prime.storage.DocumentStore @@ -0,0 +1 @@ +org.ostelco.prime.graphql.MockDocumentStore \ No newline at end of file diff --git a/graphql/src/test/resources/META-INF/services/org.ostelco.prime.storage.GraphStore b/graphql/src/test/resources/META-INF/services/org.ostelco.prime.storage.GraphStore new file mode 100644 index 000000000..1e07a09ab --- /dev/null +++ b/graphql/src/test/resources/META-INF/services/org.ostelco.prime.storage.GraphStore @@ -0,0 +1 @@ +org.ostelco.prime.graphql.MockGraphStore \ No newline at end of file diff --git a/graphql/src/test/resources/customer.graphqls b/graphql/src/test/resources/customer.graphqls new file mode 100644 index 000000000..2f3556820 --- /dev/null +++ b/graphql/src/test/resources/customer.graphqls @@ -0,0 +1,49 @@ +schema { + query: QueryType +} + +type QueryType { + context(id: String): Context +} + +type Context { + customer: Customer + bundles: [Bundle] + subscriptions: [Subscription] + products: [Product] + purchases: [Purchase] +} + +type Customer { + id: String + contactEmail: String + nickname: String + referralId: String + analyticsId: String +} + +type Bundle { + id: String + balance: Long +} + +type Subscription { + msisdn: String + analyticsId: String +} + +type Product { + sku: String + price: Price +} + +type Price { + amount: Int + currency: String +} + +type Purchase { + id: String + product: Product + timestamp: Long +} \ No newline at end of file diff --git a/graphql/src/test/resources/logback-test.xml b/graphql/src/test/resources/logback-test.xml new file mode 100644 index 000000000..1d010883b --- /dev/null +++ b/graphql/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/graphql/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/graphql/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..ca6ee9cea --- /dev/null +++ b/graphql/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/houston/.firebaserc b/houston/.firebaserc new file mode 100644 index 000000000..c4a26099d --- /dev/null +++ b/houston/.firebaserc @@ -0,0 +1,6 @@ +{ + "projects": { + "default": "redotter-admin-dev", + "prod": "redotter-admin" + } +} \ No newline at end of file diff --git a/houston/.gitignore b/houston/.gitignore new file mode 100644 index 000000000..c6e369dd4 --- /dev/null +++ b/houston/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +/coverage + +# production +/build + +.firebase/*.cache + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/houston/README.md b/houston/README.md new file mode 100644 index 000000000..b1e9aa05a --- /dev/null +++ b/houston/README.md @@ -0,0 +1,2511 @@ +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +Below you will find some information on how to perform common tasks.
+You can find the most recent version of this guide [here](https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md). + +## Table of Contents + +- [Updating to New Releases](#updating-to-new-releases) +- [Sending Feedback](#sending-feedback) +- [Folder Structure](#folder-structure) +- [Available Scripts](#available-scripts) + - [npm start](#npm-start) + - [npm test](#npm-test) + - [npm run build](#npm-run-build) + - [npm run eject](#npm-run-eject) +- [Supported Browsers](#supported-browsers) +- [Supported Language Features](#supported-language-features) +- [Syntax Highlighting in the Editor](#syntax-highlighting-in-the-editor) +- [Displaying Lint Output in the Editor](#displaying-lint-output-in-the-editor) +- [Debugging in the Editor](#debugging-in-the-editor) +- [Formatting Code Automatically](#formatting-code-automatically) +- [Changing the Page ``](#changing-the-page-title) +- [Installing a Dependency](#installing-a-dependency) +- [Importing a Component](#importing-a-component) +- [Code Splitting](#code-splitting) +- [Adding a Stylesheet](#adding-a-stylesheet) +- [Adding a CSS Modules Stylesheet](#adding-a-css-modules-stylesheet) +- [Adding a Sass Stylesheet](#adding-a-sass-stylesheet) +- [Post-Processing CSS](#post-processing-css) +- [Adding Images, Fonts, and Files](#adding-images-fonts-and-files) +- [Adding SVGs](#adding-svgs) +- [Using the `public` Folder](#using-the-public-folder) + - [Changing the HTML](#changing-the-html) + - [Adding Assets Outside of the Module System](#adding-assets-outside-of-the-module-system) + - [When to Use the `public` Folder](#when-to-use-the-public-folder) +- [Using Global Variables](#using-global-variables) +- [Adding Bootstrap](#adding-bootstrap) + - [Using a Custom Theme](#using-a-custom-theme) +- [Adding Flow](#adding-flow) +- [Adding Relay](#adding-relay) +- [Adding a Router](#adding-a-router) +- [Adding Custom Environment Variables](#adding-custom-environment-variables) + - [Referencing Environment Variables in the HTML](#referencing-environment-variables-in-the-html) + - [Adding Temporary Environment Variables In Your Shell](#adding-temporary-environment-variables-in-your-shell) + - [Adding Development Environment Variables In `.env`](#adding-development-environment-variables-in-env) +- [Can I Use Decorators?](#can-i-use-decorators) +- [Fetching Data with AJAX Requests](#fetching-data-with-ajax-requests) +- [Integrating with an API Backend](#integrating-with-an-api-backend) + - [Node](#node) + - [Ruby on Rails](#ruby-on-rails) +- [Proxying API Requests in Development](#proxying-api-requests-in-development) + - ["Invalid Host Header" Errors After Configuring Proxy](#invalid-host-header-errors-after-configuring-proxy) + - [Configuring the Proxy Manually](#configuring-the-proxy-manually) +- [Using HTTPS in Development](#using-https-in-development) +- [Generating Dynamic `<meta>` Tags on the Server](#generating-dynamic-meta-tags-on-the-server) +- [Pre-Rendering into Static HTML Files](#pre-rendering-into-static-html-files) +- [Injecting Data from the Server into the Page](#injecting-data-from-the-server-into-the-page) +- [Running Tests](#running-tests) + - [Filename Conventions](#filename-conventions) + - [Command Line Interface](#command-line-interface) + - [Version Control Integration](#version-control-integration) + - [Writing Tests](#writing-tests) + - [Testing Components](#testing-components) + - [Using Third Party Assertion Libraries](#using-third-party-assertion-libraries) + - [Initializing Test Environment](#initializing-test-environment) + - [Focusing and Excluding Tests](#focusing-and-excluding-tests) + - [Coverage Reporting](#coverage-reporting) + - [Continuous Integration](#continuous-integration) + - [Disabling jsdom](#disabling-jsdom) + - [Snapshot Testing](#snapshot-testing) + - [Editor Integration](#editor-integration) +- [Debugging Tests](#debugging-tests) + - [Debugging Tests in Chrome](#debugging-tests-in-chrome) + - [Debugging Tests in Visual Studio Code](#debugging-tests-in-visual-studio-code) +- [Developing Components in Isolation](#developing-components-in-isolation) + - [Getting Started with Storybook](#getting-started-with-storybook) + - [Getting Started with Styleguidist](#getting-started-with-styleguidist) +- [Publishing Components to npm](#publishing-components-to-npm) +- [Making a Progressive Web App](#making-a-progressive-web-app) + - [Why Opt-in?](#why-opt-in) + - [Offline-First Considerations](#offline-first-considerations) + - [Progressive Web App Metadata](#progressive-web-app-metadata) +- [Analyzing the Bundle Size](#analyzing-the-bundle-size) +- [Deployment](#deployment) + - [Static Server](#static-server) + - [Other Solutions](#other-solutions) + - [Serving Apps with Client-Side Routing](#serving-apps-with-client-side-routing) + - [Building for Relative Paths](#building-for-relative-paths) + - [Customizing Environment Variables for Arbitrary Build Environments](#customizing-environment-variables-for-arbitrary-build-environments) + - [Azure](#azure) + - [Firebase](#firebase) + - [GitHub Pages](#github-pages) + - [Heroku](#heroku) + - [Netlify](#netlify) + - [Now](#now) + - [S3 and CloudFront](#s3-and-cloudfront) + - [Surge](#surge) +- [Advanced Configuration](#advanced-configuration) +- [Troubleshooting](#troubleshooting) + - [`npm start` doesn’t detect changes](#npm-start-doesnt-detect-changes) + - [`npm test` hangs or crashes on macOS Sierra](#npm-test-hangs-or-crashes-on-macos-sierra) + - [`npm run build` exits too early](#npm-run-build-exits-too-early) + - [`npm run build` fails on Heroku](#npm-run-build-fails-on-heroku) + - [`npm run build` fails to minify](#npm-run-build-fails-to-minify) + - [Moment.js locales are missing](#momentjs-locales-are-missing) +- [Alternatives to Ejecting](#alternatives-to-ejecting) +- [Something Missing?](#something-missing) + +## Updating to New Releases + +Create React App is divided into two packages: + +- `create-react-app` is a global command-line utility that you use to create new projects. +- `react-scripts` is a development dependency in the generated projects (including this one). + +You almost never need to update `create-react-app` itself: it delegates all the setup to `react-scripts`. + +When you run `create-react-app`, it always creates the project with the latest version of `react-scripts` so you’ll get all the new features and improvements in newly created apps automatically. + +To update an existing project to a new version of `react-scripts`, [open the changelog](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md), find the version you’re currently on (check `package.json` in this folder if you’re not sure), and apply the migration instructions for the newer versions. + +In most cases bumping the `react-scripts` version in `package.json` and running `npm install` (or `yarn install`) in this folder should be enough, but it’s good to consult the [changelog](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md) for potential breaking changes. + +We commit to keeping the breaking changes minimal so you can upgrade `react-scripts` painlessly. + +## Sending Feedback + +We are always open to [your feedback](https://github.com/facebook/create-react-app/issues). + +## Folder Structure + +After creation, your project should look like this: + +``` +my-app/ + README.md + node_modules/ + package.json + public/ + index.html + favicon.ico + src/ + App.css + App.js + App.test.js + index.css + index.js + logo.svg +``` + +For the project to build, **these files must exist with exact filenames**: + +- `public/index.html` is the page template; +- `src/index.js` is the JavaScript entry point. + +You can delete or rename the other files. + +You may create subdirectories inside `src`. For faster rebuilds, only files inside `src` are processed by Webpack.<br> +You need to **put any JS and CSS files inside `src`**, otherwise Webpack won’t see them. + +Only files inside `public` can be used from `public/index.html`.<br> +Read instructions below for using assets from JavaScript and HTML. + +You can, however, create more top-level directories.<br> +They will not be included in the production build so you can use them for things like documentation. + +## Available Scripts + +In the project directory, you can run: + +### `npm start` + +Runs the app in the development mode.<br> +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.<br> +You will also see any lint errors in the console. + +### `npm test` + +Launches the test runner in the interactive watch mode.<br> +See the section about [running tests](#running-tests) for more information. + +### `npm run build` + +Builds the app for production to the `build` folder.<br> +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.<br> +Your app is ready to be deployed! + +See the section about [deployment](#deployment) for more information. + +### `npm run eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Supported Browsers + +By default, the generated project supports all modern browsers.<br> +Support for Internet Explorer 9, 10, and 11 requires [polyfills](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md). + +### Supported Language Features + +This project supports a superset of the latest JavaScript standard.<br> +In addition to [ES6](https://github.com/lukehoban/es6features) syntax features, it also supports: + +- [Exponentiation Operator](https://github.com/rwaldron/exponentiation-operator) (ES2016). +- [Async/await](https://github.com/tc39/ecmascript-asyncawait) (ES2017). +- [Object Rest/Spread Properties](https://github.com/tc39/proposal-object-rest-spread) (ES2018). +- [Dynamic import()](https://github.com/tc39/proposal-dynamic-import) (stage 3 proposal) +- [Class Fields and Static Properties](https://github.com/tc39/proposal-class-public-fields) (part of stage 3 proposal). +- [JSX](https://facebook.github.io/react/docs/introducing-jsx.html) and [Flow](https://flow.org/) syntax. + +Learn more about [different proposal stages](https://babeljs.io/docs/plugins/#presets-stage-x-experimental-presets-). + +While we recommend using experimental proposals with some caution, Facebook heavily uses these features in the product code, so we intend to provide [codemods](https://medium.com/@cpojer/effective-javascript-codemods-5a6686bb46fb) if any of these proposals change in the future. + +Note that **this project includes no [polyfills](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md)** by default. + +If you use any other ES6+ features that need **runtime support** (such as `Array.from()` or `Symbol`), make sure you are [including the appropriate polyfills manually](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md), or that the browsers you are targeting already support them. + +## Syntax Highlighting in the Editor + +To configure the syntax highlighting in your favorite text editor, head to the [relevant Babel documentation page](https://babeljs.io/docs/editors) and follow the instructions. Some of the most popular editors are covered. + +## Displaying Lint Output in the Editor + +> Note: this feature is available with `react-scripts@0.2.0` and higher.<br> +> It also only works with npm 3 or higher. + +Some editors, including Sublime Text, Atom, and Visual Studio Code, provide plugins for ESLint. + +They are not required for linting. You should see the linter output right in your terminal as well as the browser console. However, if you prefer the lint results to appear right in your editor, there are some extra steps you can do. + +You would need to install an ESLint plugin for your editor first. Then, add a file called `.eslintrc` to the project root: + +```js +{ + "extends": "react-app" +} +``` + +Now your editor should report the linting warnings. + +Note that even if you edit your `.eslintrc` file further, these changes will **only affect the editor integration**. They won’t affect the terminal and in-browser lint output. This is because Create React App intentionally provides a minimal set of rules that find common mistakes. + +If you want to enforce a coding style for your project, consider using [Prettier](https://github.com/jlongster/prettier) instead of ESLint style rules. + +## Debugging in the Editor + +**This feature is currently only supported by [Visual Studio Code](https://code.visualstudio.com) and [WebStorm](https://www.jetbrains.com/webstorm/).** + +Visual Studio Code and WebStorm support debugging out of the box with Create React App. This enables you as a developer to write and debug your React code without leaving the editor, and most importantly it enables you to have a continuous development workflow, where context switching is minimal, as you don’t have to switch between tools. + +### Visual Studio Code + +You would need to have the latest version of [VS Code](https://code.visualstudio.com) and VS Code [Chrome Debugger Extension](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) installed. + +Then add the block below to your `launch.json` file and put it inside the `.vscode` folder in your app’s root directory. + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Chrome", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000", + "webRoot": "${workspaceRoot}/src", + "sourceMapPathOverrides": { + "webpack:///src/*": "${webRoot}/*" + } + } + ] +} +``` + +> Note: the URL may be different if you've made adjustments via the [HOST or PORT environment variables](#advanced-configuration). + +Start your app by running `npm start`, and start debugging in VS Code by pressing `F5` or by clicking the green debug icon. You can now write code, set breakpoints, make changes to the code, and debug your newly modified code—all from your editor. + +Having problems with VS Code Debugging? Please see their [troubleshooting guide](https://github.com/Microsoft/vscode-chrome-debug/blob/master/README.md#troubleshooting). + +### WebStorm + +You would need to have [WebStorm](https://www.jetbrains.com/webstorm/) and [JetBrains IDE Support](https://chrome.google.com/webstore/detail/jetbrains-ide-support/hmhgeddbohgjknpmjagkdomcpobmllji) Chrome extension installed. + +In the WebStorm menu `Run` select `Edit Configurations...`. Then click `+` and select `JavaScript Debug`. Paste `http://localhost:3000` into the URL field and save the configuration. + +> Note: the URL may be different if you've made adjustments via the [HOST or PORT environment variables](#advanced-configuration). + +Start your app by running `npm start`, then press `^D` on macOS or `F9` on Windows and Linux or click the green debug icon to start debugging in WebStorm. + +The same way you can debug your application in IntelliJ IDEA Ultimate, PhpStorm, PyCharm Pro, and RubyMine. + +## Formatting Code Automatically + +Prettier is an opinionated code formatter with support for JavaScript, CSS and JSON. With Prettier you can format the code you write automatically to ensure a code style within your project. See the [Prettier's GitHub page](https://github.com/prettier/prettier) for more information, and look at this [page to see it in action](https://prettier.github.io/prettier/). + +To format our code whenever we make a commit in git, we need to install the following dependencies: + +```sh +npm install --save husky lint-staged prettier +``` + +Alternatively you may use `yarn`: + +```sh +yarn add husky lint-staged prettier +``` + +- `husky` makes it easy to use githooks as if they are npm scripts. +- `lint-staged` allows us to run scripts on staged files in git. See this [blog post about lint-staged to learn more about it](https://medium.com/@okonetchnikov/make-linting-great-again-f3890e1ad6b8). +- `prettier` is the JavaScript formatter we will run before commits. + +Now we can make sure every file is formatted correctly by adding a few lines to the `package.json` in the project root. + +Add the following field to the `package.json` section: + +```diff ++ "husky": { ++ "hooks": { ++ "pre-commit": "lint-staged" ++ } ++ } +``` + +Next we add a 'lint-staged' field to the `package.json`, for example: + +```diff + "dependencies": { + // ... + }, ++ "lint-staged": { ++ "src/**/*.{js,jsx,json,css}": [ ++ "prettier --single-quote --write", ++ "git add" ++ ] ++ }, + "scripts": { +``` + +Now, whenever you make a commit, Prettier will format the changed files automatically. You can also run `./node_modules/.bin/prettier --single-quote --write "src/**/*.{js,jsx}"` to format your entire project for the first time. + +Next you might want to integrate Prettier in your favorite editor. Read the section on [Editor Integration](https://prettier.io/docs/en/editors.html) on the Prettier GitHub page. + +## Changing the Page `<title>` + +You can find the source HTML file in the `public` folder of the generated project. You may edit the `<title>` tag in it to change the title from “React App” to anything else. + +Note that normally you wouldn’t edit files in the `public` folder very often. For example, [adding a stylesheet](#adding-a-stylesheet) is done without touching the HTML. + +If you need to dynamically update the page title based on the content, you can use the browser [`document.title`](https://developer.mozilla.org/en-US/docs/Web/API/Document/title) API. For more complex scenarios when you want to change the title from React components, you can use [React Helmet](https://github.com/nfl/react-helmet), a third party library. + +If you use a custom server for your app in production and want to modify the title before it gets sent to the browser, you can follow advice in [this section](#generating-dynamic-meta-tags-on-the-server). Alternatively, you can pre-build each page as a static HTML file which then loads the JavaScript bundle, which is covered [here](#pre-rendering-into-static-html-files). + +## Installing a Dependency + +The generated project includes React and ReactDOM as dependencies. It also includes a set of scripts used by Create React App as a development dependency. You may install other dependencies (for example, React Router) with `npm`: + +```sh +npm install --save react-router-dom +``` + +Alternatively you may use `yarn`: + +```sh +yarn add react-router-dom +``` + +This works for any library, not just `react-router-dom`. + +## Importing a Component + +This project setup supports ES6 modules thanks to Webpack.<br> +While you can still use `require()` and `module.exports`, we encourage you to use [`import` and `export`](http://exploringjs.com/es6/ch_modules.html) instead. + +For example: + +### `Button.js` + +```js +import React, { Component } from 'react'; + +class Button extends Component { + render() { + // ... + } +} + +export default Button; // Don’t forget to use export default! +``` + +### `DangerButton.js` + +```js +import React, { Component } from 'react'; +import Button from './Button'; // Import a component from another file + +class DangerButton extends Component { + render() { + return <Button color="red" />; + } +} + +export default DangerButton; +``` + +Be aware of the [difference between default and named exports](http://stackoverflow.com/questions/36795819/react-native-es-6-when-should-i-use-curly-braces-for-import/36796281#36796281). It is a common source of mistakes. + +We suggest that you stick to using default imports and exports when a module only exports a single thing (for example, a component). That’s what you get when you use `export default Button` and `import Button from './Button'`. + +Named exports are useful for utility modules that export several functions. A module may have at most one default export and as many named exports as you like. + +Learn more about ES6 modules: + +- [When to use the curly braces?](http://stackoverflow.com/questions/36795819/react-native-es-6-when-should-i-use-curly-braces-for-import/36796281#36796281) +- [Exploring ES6: Modules](http://exploringjs.com/es6/ch_modules.html) +- [Understanding ES6: Modules](https://leanpub.com/understandinges6/read#leanpub-auto-encapsulating-code-with-modules) + +## Code Splitting + +Instead of downloading the entire app before users can use it, code splitting allows you to split your code into small chunks which you can then load on demand. + +This project setup supports code splitting via [dynamic `import()`](http://2ality.com/2017/01/import-operator.html#loading-code-on-demand). Its [proposal](https://github.com/tc39/proposal-dynamic-import) is in stage 3. The `import()` function-like form takes the module name as an argument and returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) which always resolves to the namespace object of the module. + +Here is an example: + +### `moduleA.js` + +```js +const moduleA = 'Hello'; + +export { moduleA }; +``` + +### `App.js` + +```js +import React, { Component } from 'react'; + +class App extends Component { + handleClick = () => { + import('./moduleA') + .then(({ moduleA }) => { + // Use moduleA + }) + .catch(err => { + // Handle failure + }); + }; + + render() { + return ( + <div> + <button onClick={this.handleClick}>Load</button> + </div> + ); + } +} + +export default App; +``` + +This will make `moduleA.js` and all its unique dependencies as a separate chunk that only loads after the user clicks the 'Load' button. + +You can also use it with `async` / `await` syntax if you prefer it. + +### With React Router + +If you are using React Router check out [this tutorial](http://serverless-stack.com/chapters/code-splitting-in-create-react-app.html) on how to use code splitting with it. You can find the companion GitHub repository [here](https://github.com/AnomalyInnovations/serverless-stack-demo-client/tree/code-splitting-in-create-react-app). + +Also check out the [Code Splitting](https://reactjs.org/docs/code-splitting.html) section in React documentation. + +## Adding a Stylesheet + +This project setup uses [Webpack](https://webpack.js.org/) for handling all assets. Webpack offers a custom way of “extending” the concept of `import` beyond JavaScript. To express that a JavaScript file depends on a CSS file, you need to **import the CSS from the JavaScript file**: + +### `Button.css` + +```css +.Button { + padding: 20px; +} +``` + +### `Button.js` + +```js +import React, { Component } from 'react'; +import './Button.css'; // Tell Webpack that Button.js uses these styles + +class Button extends Component { + render() { + // You can use them as regular CSS styles + return <div className="Button" />; + } +} +``` + +**This is not required for React** but many people find this feature convenient. You can read about the benefits of this approach [here](https://medium.com/seek-blog/block-element-modifying-your-javascript-components-d7f99fcab52b). However you should be aware that this makes your code less portable to other build tools and environments than Webpack. + +In development, expressing dependencies this way allows your styles to be reloaded on the fly as you edit them. In production, all CSS files will be concatenated into a single minified `.css` file in the build output. + +If you are concerned about using Webpack-specific semantics, you can put all your CSS right into `src/index.css`. It would still be imported from `src/index.js`, but you could always remove that import if you later migrate to a different build tool. + +## Adding a CSS Modules Stylesheet + +> Note: this feature is available with `react-scripts@2.0.0` and higher. + +This project supports [CSS Modules](https://github.com/css-modules/css-modules) alongside regular stylesheets using the `[name].module.css` file naming convention. CSS Modules allows the scoping of CSS by automatically creating a unique classname of the format `[filename]\_[classname]\_\_[hash]`. + +> **Tip:** Should you want to preprocess a stylesheet with Sass then make sure to [follow the installation instructions](#adding-a-sass-stylesheet) and then change the stylesheet file extension as follows: `[name].module.scss` or `[name].module.sass`. + +CSS Modules let you use the same CSS class name in different files without worrying about naming clashes. Learn more about CSS Modules [here](https://css-tricks.com/css-modules-part-1-need/). + +### `Button.module.css` + +```css +.error { + background-color: red; +} +``` + +### `another-stylesheet.css` + +```css +.error { + color: red; +} +``` + +### `Button.js` + +```js +import React, { Component } from 'react'; +import styles from './Button.module.css'; // Import css modules stylesheet as styles +import './another-stylesheet.css'; // Import regular stylesheet + +class Button extends Component { + render() { + // reference as a js object + return <button className={styles.error}>Error Button</button>; + } +} +``` + +### Result + +No clashes from other `.error` class names + +```html +<!-- This button has red background but not red text --> +<button class="Button_error_ax7yz"></div> +``` + +**This is an optional feature.** Regular `<link>` stylesheets and CSS files are fully supported. CSS Modules are turned on for files ending with the `.module.css` extension. + +## Adding a Sass Stylesheet + +> Note: this feature is available with `react-scripts@2.0.0` and higher. + +Generally, we recommend that you don’t reuse the same CSS classes across different components. For example, instead of using a `.Button` CSS class in `<AcceptButton>` and `<RejectButton>` components, we recommend creating a `<Button>` component with its own `.Button` styles, that both `<AcceptButton>` and `<RejectButton>` can render (but [not inherit](https://facebook.github.io/react/docs/composition-vs-inheritance.html)). + +Following this rule often makes CSS preprocessors less useful, as features like mixins and nesting are replaced by component composition. You can, however, integrate a CSS preprocessor if you find it valuable. + +To use Sass, first install `node-sass`: + +```bash +$ npm install node-sass --save +$ # or +$ yarn add node-sass +``` + +Now you can rename `src/App.css` to `src/App.scss` and update `src/App.js` to import `src/App.scss`. +This file and any other file will be automatically compiled if imported with the extension `.scss` or `.sass`. + +To share variables between Sass files, you can use Sass imports. For example, `src/App.scss` and other component style files could include `@import "./shared.scss";` with variable definitions. + +This will allow you to do imports like + +```scss +@import 'styles/_colors.scss'; // assuming a styles directory under src/ +@import '~nprogress/nprogress'; // importing a css file from the nprogress node module +``` + +> **Tip:** You can opt into using this feature with [CSS modules](#adding-a-css-modules-stylesheet) too! + +> **Note:** You must prefix imports from `node_modules` with `~` as displayed above. + +## Post-Processing CSS + +This project setup minifies your CSS and adds vendor prefixes to it automatically through [Autoprefixer](https://github.com/postcss/autoprefixer) so you don’t need to worry about it. + +Support for new CSS features like the [`all` property](https://developer.mozilla.org/en-US/docs/Web/CSS/all), [`break` properties](https://www.w3.org/TR/css-break-3/#breaking-controls), [custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables), and [media query ranges](https://www.w3.org/TR/mediaqueries-4/#range-context) are automatically polyfilled to add support for older browsers. + +You can customize your target support browsers by adjusting the `browserslist` key in `package.json` accoring to the [Browserslist specification](https://github.com/browserslist/browserslist#readme). + +For example, this: + +```css +.App { + display: flex; + flex-direction: row; + align-items: center; +} +``` + +becomes this: + +```css +.App { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} +``` + +If you need to disable autoprefixing for some reason, [follow this section](https://github.com/postcss/autoprefixer#disabling). + +[CSS Grid Layout](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout) prefixing is disabled by default, but it will **not** strip manual prefixing. +If you'd like to opt-in to CSS Grid prefixing, [first familiarize yourself about its limitations](https://github.com/postcss/autoprefixer#does-autoprefixer-polyfill-grid-layout-for-ie).<br> +To enable CSS Grid prefixing, add `/* autoprefixer grid: on */` to the top of your CSS file. + +## Adding Images, Fonts, and Files + +With Webpack, using static assets like images and fonts works similarly to CSS. + +You can **`import` a file right in a JavaScript module**. This tells Webpack to include that file in the bundle. Unlike CSS imports, importing a file gives you a string value. This value is the final path you can reference in your code, e.g. as the `src` attribute of an image or the `href` of a link to a PDF. + +To reduce the number of requests to the server, importing images that are less than 10,000 bytes returns a [data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) instead of a path. This applies to the following file extensions: bmp, gif, jpg, jpeg, and png. SVG files are excluded due to [#1153](https://github.com/facebook/create-react-app/issues/1153). + +Here is an example: + +```js +import React from 'react'; +import logo from './logo.png'; // Tell Webpack this JS file uses this image + +console.log(logo); // /logo.84287d09.png + +function Header() { + // Import result is the URL of your image + return <img src={logo} alt="Logo" />; +} + +export default Header; +``` + +This ensures that when the project is built, Webpack will correctly move the images into the build folder, and provide us with correct paths. + +This works in CSS too: + +```css +.Logo { + background-image: url(./logo.png); +} +``` + +Webpack finds all relative module references in CSS (they start with `./`) and replaces them with the final paths from the compiled bundle. If you make a typo or accidentally delete an important file, you will see a compilation error, just like when you import a non-existent JavaScript module. The final filenames in the compiled bundle are generated by Webpack from content hashes. If the file content changes in the future, Webpack will give it a different name in production so you don’t need to worry about long-term caching of assets. + +Please be advised that this is also a custom feature of Webpack. + +**It is not required for React** but many people enjoy it (and React Native uses a similar mechanism for images).<br> +An alternative way of handling static assets is described in the next section. + +### Adding SVGs + +> Note: this feature is available with `react-scripts@2.0.0` and higher. + +One way to add SVG files was described in the section above. You can also import SVGs directly as React components. You can use either of the two approaches. In your code it would look like this: + +```js +import { ReactComponent as Logo } from './logo.svg'; +const App = () => ( + <div> + {/* Logo is an actual React component */} + <Logo /> + </div> +); + +``` + +This is handy if you don't want to load SVG as a separate file. Don't forget the curly braces in the import! The `ReactComponent` import name is special and tells Create React App that you want a React component that renders an SVG, rather than its filename. + +## Using the `public` Folder + +> Note: this feature is available with `react-scripts@0.5.0` and higher. + +### Changing the HTML + +The `public` folder contains the HTML file so you can tweak it, for example, to [set the page title](#changing-the-page-title). +The `<script>` tag with the compiled code will be added to it automatically during the build process. + +### Adding Assets Outside of the Module System + +You can also add other assets to the `public` folder. + +Note that we normally encourage you to `import` assets in JavaScript files instead. +For example, see the sections on [adding a stylesheet](#adding-a-stylesheet) and [adding images and fonts](#adding-images-fonts-and-files). +This mechanism provides a number of benefits: + +- Scripts and stylesheets get minified and bundled together to avoid extra network requests. +- Missing files cause compilation errors instead of 404 errors for your users. +- Result filenames include content hashes so you don’t need to worry about browsers caching their old versions. + +However there is an **escape hatch** that you can use to add an asset outside of the module system. + +If you put a file into the `public` folder, it will **not** be processed by Webpack. Instead it will be copied into the build folder untouched. To reference assets in the `public` folder, you need to use a special variable called `PUBLIC_URL`. + +Inside `index.html`, you can use it like this: + +```html +<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> +``` + +Only files inside the `public` folder will be accessible by `%PUBLIC_URL%` prefix. If you need to use a file from `src` or `node_modules`, you’ll have to copy it there to explicitly specify your intention to make this file a part of the build. + +When you run `npm run build`, Create React App will substitute `%PUBLIC_URL%` with a correct absolute path so your project works even if you use client-side routing or host it at a non-root URL. + +In JavaScript code, you can use `process.env.PUBLIC_URL` for similar purposes: + +```js +render() { + // Note: this is an escape hatch and should be used sparingly! + // Normally we recommend using `import` for getting asset URLs + // as described in “Adding Images and Fonts” above this section. + return <img src={process.env.PUBLIC_URL + '/img/logo.png'} />; +} +``` + +Keep in mind the downsides of this approach: + +- None of the files in `public` folder get post-processed or minified. +- Missing files will not be called at compilation time, and will cause 404 errors for your users. +- Result filenames won’t include content hashes so you’ll need to add query arguments or rename them every time they change. + +### When to Use the `public` Folder + +Normally we recommend importing [stylesheets](#adding-a-stylesheet), [images, and fonts](#adding-images-fonts-and-files) from JavaScript. +The `public` folder is useful as a workaround for a number of less common cases: + +- You need a file with a specific name in the build output, such as [`manifest.webmanifest`](https://developer.mozilla.org/en-US/docs/Web/Manifest). +- You have thousands of images and need to dynamically reference their paths. +- You want to include a small script like [`pace.js`](http://github.hubspot.com/pace/docs/welcome/) outside of the bundled code. +- Some library may be incompatible with Webpack and you have no other option but to include it as a `<script>` tag. + +Note that if you add a `<script>` that declares global variables, you also need to read the next section on using them. + +## Using Global Variables + +When you include a script in the HTML file that defines global variables and try to use one of these variables in the code, the linter will complain because it cannot see the definition of the variable. + +You can avoid this by reading the global variable explicitly from the `window` object, for example: + +```js +const $ = window.$; +``` + +This makes it obvious you are using a global variable intentionally rather than because of a typo. + +Alternatively, you can force the linter to ignore any line by adding `// eslint-disable-line` after it. + +## Adding Bootstrap + +You don’t have to use [reactstrap](https://reactstrap.github.io/) together with React but it is a popular library for integrating Bootstrap with React apps. If you need it, you can integrate it with Create React App by following these steps: + +Install reactstrap and Bootstrap from npm. reactstrap does not include Bootstrap CSS so this needs to be installed as well: + +```sh +npm install --save reactstrap bootstrap@4 +``` + +Alternatively you may use `yarn`: + +```sh +yarn add bootstrap@4 reactstrap +``` + +Import Bootstrap CSS and optionally Bootstrap theme CSS in the beginning of your `src/index.js` file: + +```js +import 'bootstrap/dist/css/bootstrap.css'; +// Put any other imports below so that CSS from your +// components takes precedence over default styles. +``` + +Import required reactstrap components within `src/App.js` file or your custom component files: + +```js +import { Button } from 'reactstrap'; +``` + +Now you are ready to use the imported reactstrap components within your component hierarchy defined in the render method. Here is an example [`App.js`](https://gist.githubusercontent.com/zx6658/d9f128cd57ca69e583ea2b5fea074238/raw/a56701c142d0c622eb6c20a457fbc01d708cb485/App.js) redone using reactstrap. + +### Using a Custom Theme + +> Note: this feature is available with `react-scripts@2.0.0` and higher. + +Sometimes you might need to tweak the visual styles of Bootstrap (or equivalent package).<br> +As of `react-scripts@2.0.0` you can import `.scss` files. This makes it possible to use a package's built-in Sass variables for global style preferences. + +To customize Bootstrap, create a file called `src/custom.scss` (or similar) and import the Bootstrap source stylesheet. Add any overrides _before_ the imported file(s). You can reference [Bootstrap's documentation](http://getbootstrap.com/docs/4.1/getting-started/theming/#css-variables) for the names of the available variables. + +```scss +// Override default variables before the import +$body-bg: #000; + +// Import Bootstrap and its default variables +@import '~bootstrap/scss/bootstrap.scss'; +``` + +> **Note:** You must prefix imports from `node_modules` with `~` as displayed above. + +Finally, import the newly created `.scss` file instead of the default Bootstrap `.css` in the beginning of your `src/index.js` file, for example: + +```javascript +import './custom.scss'; +``` + +## Adding Flow + +Flow is a static type checker that helps you write code with fewer bugs. Check out this [introduction to using static types in JavaScript](https://medium.com/@preethikasireddy/why-use-static-types-in-javascript-part-1-8382da1e0adb) if you are new to this concept. + +Recent versions of [Flow](https://flow.org/) work with Create React App projects out of the box. + +To add Flow to a Create React App project, follow these steps: + +1. Run `npm install --save flow-bin` (or `yarn add flow-bin`). +2. Add `"flow": "flow"` to the `scripts` section of your `package.json`. +3. Run `npm run flow init` (or `yarn flow init`) to create a [`.flowconfig` file](https://flow.org/en/docs/config/) in the root directory. +4. Add `// @flow` to any files you want to type check (for example, to `src/App.js`). + +Now you can run `npm run flow` (or `yarn flow`) to check the files for type errors. +You can optionally use an IDE like [Nuclide](https://nuclide.io/docs/languages/flow/) for a better integrated experience. +In the future we plan to integrate it into Create React App even more closely. + +To learn more about Flow, check out [its documentation](https://flow.org/). + +## Adding Relay + +Relay is a framework for building data-driven React applications powered by GraphQL. The current release candidate of Relay works with Create React App projects out of the box using Babel Macros. Simply set up your project as laid out in the [Relay documentation](https://facebook.github.io/relay/), then make sure you have a version of the babel plugin providing the macro. + +To add it, run: + +```sh +npm install --save --dev babel-plugin-relay@dev +``` + +Alternatively you may use `yarn`: + +```sh +yarn upgrade babel-plugin-relay@dev +``` + +Then, wherever you use the `graphql` template tag, import the macro: + +```js +import graphql from 'babel-plugin-relay/macro'; +// instead of: +// import { graphql } from "babel-plugin-relay" + +graphql` + query UserQuery { + viewer { + id + } + } +`; +``` + +To learn more about Relay, check out [its documentation](https://facebook.github.io/relay/). + +## Adding a Router + +Create React App doesn't prescribe a specific routing solution, but [React Router](https://reacttraining.com/react-router/web/) is the most popular one. + +To add it, run: + +```sh +npm install --save react-router-dom +``` + +Alternatively you may use `yarn`: + +```sh +yarn add react-router-dom +``` + +To try it, delete all the code in `src/App.js` and replace it with any of the examples on its website. The [Basic Example](https://reacttraining.com/react-router/web/example/basic) is a good place to get started. + +Note that [you may need to configure your production server to support client-side routing](#serving-apps-with-client-side-routing) before deploying your app. + +## Adding Custom Environment Variables + +> Note: this feature is available with `react-scripts@0.2.3` and higher. + +Your project can consume variables declared in your environment as if they were declared locally in your JS files. By +default you will have `NODE_ENV` defined for you, and any other environment variables starting with +`REACT_APP_`. + +**The environment variables are embedded during the build time**. Since Create React App produces a static HTML/CSS/JS bundle, it can’t possibly read them at runtime. To read them at runtime, you would need to load HTML into memory on the server and replace placeholders in runtime, just like [described here](#injecting-data-from-the-server-into-the-page). Alternatively you can rebuild the app on the server anytime you change them. + +> Note: You must create custom environment variables beginning with `REACT_APP_`. Any other variables except `NODE_ENV` will be ignored to avoid accidentally [exposing a private key on the machine that could have the same name](https://github.com/facebook/create-react-app/issues/865#issuecomment-252199527). Changing any environment variables will require you to restart the development server if it is running. + +These environment variables will be defined for you on `process.env`. For example, having an environment +variable named `REACT_APP_SECRET_CODE` will be exposed in your JS as `process.env.REACT_APP_SECRET_CODE`. + +There is also a special built-in environment variable called `NODE_ENV`. You can read it from `process.env.NODE_ENV`. When you run `npm start`, it is always equal to `'development'`, when you run `npm test` it is always equal to `'test'`, and when you run `npm run build` to make a production bundle, it is always equal to `'production'`. **You cannot override `NODE_ENV` manually.** This prevents developers from accidentally deploying a slow development build to production. + +These environment variables can be useful for displaying information conditionally based on where the project is +deployed or consuming sensitive data that lives outside of version control. + +First, you need to have environment variables defined. For example, let’s say you wanted to consume a secret defined +in the environment inside a `<form>`: + +```jsx +render() { + return ( + <div> + <small>You are running this application in <b>{process.env.NODE_ENV}</b> mode.</small> + <form> + <input type="hidden" defaultValue={process.env.REACT_APP_SECRET_CODE} /> + </form> + </div> + ); +} +``` + +During the build, `process.env.REACT_APP_SECRET_CODE` will be replaced with the current value of the `REACT_APP_SECRET_CODE` environment variable. Remember that the `NODE_ENV` variable will be set for you automatically. + +When you load the app in the browser and inspect the `<input>`, you will see its value set to `abcdef`, and the bold text will show the environment provided when using `npm start`: + +```html +<div> + <small>You are running this application in <b>development</b> mode.</small> + <form> + <input type="hidden" value="abcdef" /> + </form> +</div> +``` + +The above form is looking for a variable called `REACT_APP_SECRET_CODE` from the environment. In order to consume this +value, we need to have it defined in the environment. This can be done using two ways: either in your shell or in +a `.env` file. Both of these ways are described in the next few sections. + +Having access to the `NODE_ENV` is also useful for performing actions conditionally: + +```js +if (process.env.NODE_ENV !== 'production') { + analytics.disable(); +} +``` + +When you compile the app with `npm run build`, the minification step will strip out this condition, and the resulting bundle will be smaller. + +### Referencing Environment Variables in the HTML + +> Note: this feature is available with `react-scripts@0.9.0` and higher. + +You can also access the environment variables starting with `REACT_APP_` in the `public/index.html`. For example: + +```html +<title>%REACT_APP_WEBSITE_NAME% +``` + +Note that the caveats from the above section apply: + +- Apart from a few built-in variables (`NODE_ENV` and `PUBLIC_URL`), variable names must start with `REACT_APP_` to work. +- The environment variables are injected at build time. If you need to inject them at runtime, [follow this approach instead](#generating-dynamic-meta-tags-on-the-server). + +### Adding Temporary Environment Variables In Your Shell + +Defining environment variables can vary between OSes. It’s also important to know that this manner is temporary for the +life of the shell session. + +#### Windows (cmd.exe) + +```cmd +set "REACT_APP_SECRET_CODE=abcdef" && npm start +``` + +(Note: Quotes around the variable assignment are required to avoid a trailing whitespace.) + +#### Windows (Powershell) + +```Powershell +($env:REACT_APP_SECRET_CODE = "abcdef") -and (npm start) +``` + +#### Linux, macOS (Bash) + +```bash +REACT_APP_SECRET_CODE=abcdef npm start +``` + +### Adding Development Environment Variables In `.env` + +> Note: this feature is available with `react-scripts@0.5.0` and higher. + +To define permanent environment variables, create a file called `.env` in the root of your project: + +``` +REACT_APP_SECRET_CODE=abcdef +``` + +> Note: You must create custom environment variables beginning with `REACT_APP_`. Any other variables except `NODE_ENV` will be ignored to avoid [accidentally exposing a private key on the machine that could have the same name](https://github.com/facebook/create-react-app/issues/865#issuecomment-252199527). Changing any environment variables will require you to restart the development server if it is running. + +`.env` files **should be** checked into source control (with the exclusion of `.env*.local`). + +#### What other `.env` files can be used? + +> Note: this feature is **available with `react-scripts@1.0.0` and higher**. + +- `.env`: Default. +- `.env.local`: Local overrides. **This file is loaded for all environments except test.** +- `.env.development`, `.env.test`, `.env.production`: Environment-specific settings. +- `.env.development.local`, `.env.test.local`, `.env.production.local`: Local overrides of environment-specific settings. + +Files on the left have more priority than files on the right: + +- `npm start`: `.env.development.local`, `.env.development`, `.env.local`, `.env` +- `npm run build`: `.env.production.local`, `.env.production`, `.env.local`, `.env` +- `npm test`: `.env.test.local`, `.env.test`, `.env` (note `.env.local` is missing) + +These variables will act as the defaults if the machine does not explicitly set them.
+Please refer to the [dotenv documentation](https://github.com/motdotla/dotenv) for more details. + +> Note: If you are defining environment variables for development, your CI and/or hosting platform will most likely need +> these defined as well. Consult their documentation how to do this. For example, see the documentation for [Travis CI](https://docs.travis-ci.com/user/environment-variables/) or [Heroku](https://devcenter.heroku.com/articles/config-vars). + +#### Expanding Environment Variables In `.env` + +> Note: this feature is available with `react-scripts@1.1.0` and higher. + +Expand variables already on your machine for use in your `.env` file (using [dotenv-expand](https://github.com/motdotla/dotenv-expand)). + +For example, to get the environment variable `npm_package_version`: + +``` +REACT_APP_VERSION=$npm_package_version +# also works: +# REACT_APP_VERSION=${npm_package_version} +``` + +Or expand variables local to the current `.env` file: + +``` +DOMAIN=www.example.com +REACT_APP_FOO=$DOMAIN/foo +REACT_APP_BAR=$DOMAIN/bar +``` + +## Can I Use Decorators? + +Some popular libraries use [decorators](https://medium.com/google-developers/exploring-es7-decorators-76ecb65fb841) in their documentation.
+Create React App intentionally doesn’t support decorator syntax at the moment because: + +- It is an experimental proposal and is subject to change (in fact, it has already changed once, and will change again). +- Most libraries currently support only the old version of the proposal — which will never be a standard. + +However in many cases you can rewrite decorator-based code without decorators just as fine.
+Please refer to these two threads for reference: + +- [#214](https://github.com/facebook/create-react-app/issues/214) +- [#411](https://github.com/facebook/create-react-app/issues/411) + +Create React App will add decorator support when the specification advances to a stable stage. + +## Fetching Data with AJAX Requests + +React doesn't prescribe a specific approach to data fetching, but people commonly use either a library like [axios](https://github.com/axios/axios) or the [`fetch()` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) provided by the browser. + +The global `fetch` function allows you to easily make AJAX requests. It takes in a URL as an input and returns a `Promise` that resolves to a `Response` object. You can find more information about `fetch` [here](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). + +A Promise represents the eventual result of an asynchronous operation, you can find more information about Promises [here](https://www.promisejs.org/) and [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). Both axios and `fetch()` use Promises under the hood. You can also use the [`async / await`](https://davidwalsh.name/async-await) syntax to reduce the callback nesting. + +Make sure the [`fetch()` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) are available in your target audience's browsers. +For example, support in Internet Explorer requires a [polyfill](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md). + +You can learn more about making AJAX requests from React components in [the FAQ entry on the React website](https://reactjs.org/docs/faq-ajax.html). + +## Integrating with an API Backend + +These tutorials will help you to integrate your app with an API backend running on another port, +using `fetch()` to access it. + +### Node + +Check out [this tutorial](https://www.fullstackreact.com/articles/using-create-react-app-with-a-server/). +You can find the companion GitHub repository [here](https://github.com/fullstackreact/food-lookup-demo). + +### Ruby on Rails + +Check out [this tutorial](https://www.fullstackreact.com/articles/how-to-get-create-react-app-to-work-with-your-rails-api/). +You can find the companion GitHub repository [here](https://github.com/fullstackreact/food-lookup-demo-rails). + +### API Platform (PHP and Symfony) + +[API Platform](https://api-platform.com) is a framework designed to build API-driven projects. +It allows to create hypermedia and GraphQL APIs in minutes. +It is shipped with an official Progressive Web App generator as well as a dynamic administration interface, both built for Create React App. +Check out [this tutorial](https://api-platform.com/docs/distribution). + +## Proxying API Requests in Development + +> Note: this feature is available with `react-scripts@0.2.3` and higher. + +People often serve the front-end React app from the same host and port as their backend implementation.
+For example, a production setup might look like this after the app is deployed: + +``` +/ - static server returns index.html with React app +/todos - static server returns index.html with React app +/api/todos - server handles any /api/* requests using the backend implementation +``` + +Such setup is **not** required. However, if you **do** have a setup like this, it is convenient to write requests like `fetch('/api/todos')` without worrying about redirecting them to another host or port during development. + +To tell the development server to proxy any unknown requests to your API server in development, add a `proxy` field to your `package.json`, for example: + +```js + "proxy": "http://localhost:4000", +``` + +This way, when you `fetch('/api/todos')` in development, the development server will recognize that it’s not a static asset, and will proxy your request to `http://localhost:4000/api/todos` as a fallback. The development server will **only** attempt to send requests without `text/html` in its `Accept` header to the proxy. + +Conveniently, this avoids [CORS issues](http://stackoverflow.com/questions/21854516/understanding-ajax-cors-and-security-considerations) and error messages like this in development: + +``` +Fetch API cannot load http://localhost:4000/api/todos. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:3000' is therefore not allowed access. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled. +``` + +Keep in mind that `proxy` only has effect in development (with `npm start`), and it is up to you to ensure that URLs like `/api/todos` point to the right thing in production. You don’t have to use the `/api` prefix. Any unrecognized request without a `text/html` accept header will be redirected to the specified `proxy`. + +The `proxy` option supports HTTP, HTTPS and WebSocket connections.
+If the `proxy` option is **not** flexible enough for you, alternatively you can: + +- [Configure the proxy yourself](#configuring-the-proxy-manually) +- Enable CORS on your server ([here’s how to do it for Express](http://enable-cors.org/server_expressjs.html)). +- Use [environment variables](#adding-custom-environment-variables) to inject the right server host and port into your app. + +### "Invalid Host Header" Errors After Configuring Proxy + +When you enable the `proxy` option, you opt into a more strict set of host checks. This is necessary because leaving the backend open to remote hosts makes your computer vulnerable to DNS rebinding attacks. The issue is explained in [this article](https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a) and [this issue](https://github.com/webpack/webpack-dev-server/issues/887). + +This shouldn’t affect you when developing on `localhost`, but if you develop remotely like [described here](https://github.com/facebook/create-react-app/issues/2271), you will see this error in the browser after enabling the `proxy` option: + +> Invalid Host header + +To work around it, you can specify your public development host in a file called `.env.development` in the root of your project: + +``` +HOST=mypublicdevhost.com +``` + +If you restart the development server now and load the app from the specified host, it should work. + +If you are still having issues or if you’re using a more exotic environment like a cloud editor, you can bypass the host check completely by adding a line to `.env.development.local`. **Note that this is dangerous and exposes your machine to remote code execution from malicious websites:** + +``` +# NOTE: THIS IS DANGEROUS! +# It exposes your machine to attacks from the websites you visit. +DANGEROUSLY_DISABLE_HOST_CHECK=true +``` + +We don’t recommend this approach. + +### Configuring the Proxy Manually + +> Note: this feature is available with `react-scripts@2.0.0` and higher. + +If the `proxy` option is **not** flexible enough for you, you can get direct access to the Express app instance and hook up your own proxy middleware. + +You can use this feature in conjunction with the `proxy` property in `package.json`, but it is recommended you consolidate all of your logic into `src/setupProxy.js`. + +First, install `http-proxy-middleware` using npm or Yarn: + +```bash +$ npm install http-proxy-middleware --save +$ # or +$ yarn add http-proxy-middleware +``` + +Next, create `src/setupProxy.js` and place the following contents in it: + +```js +const proxy = require('http-proxy-middleware'); + +module.exports = function(app) { + // ... +}; +``` + +You can now register proxies as you wish! Here's an example using the above `http-proxy-middleware`: + +```js +const proxy = require('http-proxy-middleware'); + +module.exports = function(app) { + app.use(proxy('/api', { target: 'http://localhost:5000/' })); +}; +``` + +> **Note:** You do not need to import this file anywhere. It is automatically registered when you start the development server. + +> **Note:** This file only supports Node's JavaScript syntax. Be sure to only use supported language features (i.e. no support for Flow, ES Modules, etc). + +> **Note:** Passing the path to the proxy function allows you to use globbing and/or pattern matching on the path, which is more flexible than the express route matching. + +## Using HTTPS in Development + +> Note: this feature is available with `react-scripts@0.4.0` and higher. + +You may require the dev server to serve pages over HTTPS. One particular case where this could be useful is when using [the "proxy" feature](#proxying-api-requests-in-development) to proxy requests to an API server when that API server is itself serving HTTPS. + +To do this, set the `HTTPS` environment variable to `true`, then start the dev server as usual with `npm start`: + +#### Windows (cmd.exe) + +```cmd +set HTTPS=true&&npm start +``` + +(Note: the lack of whitespace is intentional.) + +#### Windows (Powershell) + +```Powershell +($env:HTTPS = $true) -and (npm start) +``` + +#### Linux, macOS (Bash) + +```bash +HTTPS=true npm start +``` + +Note that the server will use a self-signed certificate, so your web browser will almost definitely display a warning upon accessing the page. + +## Generating Dynamic `` Tags on the Server + +Since Create React App doesn’t support server rendering, you might be wondering how to make `` tags dynamic and reflect the current URL. To solve this, we recommend to add placeholders into the HTML, like this: + +```html + + + + + +``` + +Then, on the server, regardless of the backend you use, you can read `index.html` into memory and replace `__OG_TITLE__`, `__OG_DESCRIPTION__`, and any other placeholders with values depending on the current URL. Just make sure to sanitize and escape the interpolated values so that they are safe to embed into HTML! + +If you use a Node server, you can even share the route matching logic between the client and the server. However duplicating it also works fine in simple cases. + +## Pre-Rendering into Static HTML Files + +If you’re hosting your `build` with a static hosting provider you can use [react-snapshot](https://www.npmjs.com/package/react-snapshot) or [react-snap](https://github.com/stereobooster/react-snap) to generate HTML pages for each route, or relative link, in your application. These pages will then seamlessly become active, or “hydrated”, when the JavaScript bundle has loaded. + +There are also opportunities to use this outside of static hosting, to take the pressure off the server when generating and caching routes. + +The primary benefit of pre-rendering is that you get the core content of each page _with_ the HTML payload—regardless of whether or not your JavaScript bundle successfully downloads. It also increases the likelihood that each route of your application will be picked up by search engines. + +You can read more about [zero-configuration pre-rendering (also called snapshotting) here](https://medium.com/superhighfives/an-almost-static-stack-6df0a2791319). + +## Injecting Data from the Server into the Page + +Similarly to the previous section, you can leave some placeholders in the HTML that inject global variables, for example: + +```js + + + + +``` + +Then, on the server, you can replace `__SERVER_DATA__` with a JSON of real data right before sending the response. The client code can then read `window.SERVER_DATA` to use it. **Make sure to [sanitize the JSON before sending it to the client](https://medium.com/node-security/the-most-common-xss-vulnerability-in-react-js-applications-2bdffbcc1fa0) as it makes your app vulnerable to XSS attacks.** + +## Running Tests + +> Note: this feature is available with `react-scripts@0.3.0` and higher.
+ +> [Read the migration guide to learn how to enable it in older projects!](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md#migrating-from-023-to-030) + +Create React App uses [Jest](https://facebook.github.io/jest/) as its test runner. To prepare for this integration, we did a [major revamp](https://facebook.github.io/jest/blog/2016/09/01/jest-15.html) of Jest so if you heard bad things about it years ago, give it another try. + +Jest is a Node-based runner. This means that the tests always run in a Node environment and not in a real browser. This lets us enable fast iteration speed and prevent flakiness. + +While Jest provides browser globals such as `window` thanks to [jsdom](https://github.com/tmpvar/jsdom), they are only approximations of the real browser behavior. Jest is intended to be used for unit tests of your logic and your components rather than the DOM quirks. + +We recommend that you use a separate tool for browser end-to-end tests if you need them. They are beyond the scope of Create React App. + +### Filename Conventions + +Jest will look for test files with any of the following popular naming conventions: + +- Files with `.js` suffix in `__tests__` folders. +- Files with `.test.js` suffix. +- Files with `.spec.js` suffix. + +The `.test.js` / `.spec.js` files (or the `__tests__` folders) can be located at any depth under the `src` top level folder. + +We recommend to put the test files (or `__tests__` folders) next to the code they are testing so that relative imports appear shorter. For example, if `App.test.js` and `App.js` are in the same folder, the test just needs to `import App from './App'` instead of a long relative path. Colocation also helps find tests more quickly in larger projects. + +### Command Line Interface + +When you run `npm test`, Jest will launch in the watch mode. Every time you save a file, it will re-run the tests, just like `npm start` recompiles the code. + +The watcher includes an interactive command-line interface with the ability to run all tests, or focus on a search pattern. It is designed this way so that you can keep it open and enjoy fast re-runs. You can learn the commands from the “Watch Usage” note that the watcher prints after every run: + +![Jest watch mode](http://facebook.github.io/jest/img/blog/15-watch.gif) + +### Version Control Integration + +By default, when you run `npm test`, Jest will only run the tests related to files changed since the last commit. This is an optimization designed to make your tests run fast regardless of how many tests you have. However it assumes that you don’t often commit the code that doesn’t pass the tests. + +Jest will always explicitly mention that it only ran tests related to the files changed since the last commit. You can also press `a` in the watch mode to force Jest to run all tests. + +Jest will always run all tests on a [continuous integration](#continuous-integration) server or if the project is not inside a Git or Mercurial repository. + +### Writing Tests + +To create tests, add `it()` (or `test()`) blocks with the name of the test and its code. You may optionally wrap them in `describe()` blocks for logical grouping but this is neither required nor recommended. + +Jest provides a built-in `expect()` global function for making assertions. A basic test could look like this: + +```js +import sum from './sum'; + +it('sums numbers', () => { + expect(sum(1, 2)).toEqual(3); + expect(sum(2, 2)).toEqual(4); +}); +``` + +All `expect()` matchers supported by Jest are [extensively documented here](https://facebook.github.io/jest/docs/en/expect.html#content).
+You can also use [`jest.fn()` and `expect(fn).toBeCalled()`](https://facebook.github.io/jest/docs/en/expect.html#tohavebeencalled) to create “spies” or mock functions. + +### Testing Components + +There is a broad spectrum of component testing techniques. They range from a “smoke test” verifying that a component renders without throwing, to shallow rendering and testing some of the output, to full rendering and testing component lifecycle and state changes. + +Different projects choose different testing tradeoffs based on how often components change, and how much logic they contain. If you haven’t decided on a testing strategy yet, we recommend that you start with creating simple smoke tests for your components: + +```js +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +it('renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(, div); +}); +``` + +This test mounts a component and makes sure that it didn’t throw during rendering. Tests like this provide a lot of value with very little effort so they are great as a starting point, and this is the test you will find in `src/App.test.js`. + +When you encounter bugs caused by changing components, you will gain a deeper insight into which parts of them are worth testing in your application. This might be a good time to introduce more specific tests asserting specific expected output or behavior. + +If you’d like to test components in isolation from the child components they render, we recommend using [`shallow()` rendering API](http://airbnb.io/enzyme/docs/api/shallow.html) from [Enzyme](http://airbnb.io/enzyme/). To install it, run: + +```sh +npm install --save enzyme enzyme-adapter-react-16 react-test-renderer +``` + +Alternatively you may use `yarn`: + +```sh +yarn add enzyme enzyme-adapter-react-16 react-test-renderer +``` + +As of Enzyme 3, you will need to install Enzyme along with an Adapter corresponding to the version of React you are using. (The examples above use the adapter for React 16.) + +The adapter will also need to be configured in your [global setup file](#initializing-test-environment): + +#### `src/setupTests.js` + +```js +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +configure({ adapter: new Adapter() }); +``` + +> Note: Keep in mind that if you decide to "eject" before creating `src/setupTests.js`, the resulting `package.json` file won't contain any reference to it. [Read here](#initializing-test-environment) to learn how to add this after ejecting. + +Now you can write a smoke test with it: + +```js +import React from 'react'; +import { shallow } from 'enzyme'; +import App from './App'; + +it('renders without crashing', () => { + shallow(); +}); +``` + +Unlike the previous smoke test using `ReactDOM.render()`, this test only renders `` and doesn’t go deeper. For example, even if `` itself renders a ` + ) + } + { + loggedIn && (this.renderMenu()) + } + + + + ); + } +} + +function mapStateToProps(state) { + const { loggedIn, user } = state.authentication; + return { + loggedIn, + user + }; +} +const mapDispatchToProps = { + login: authActions.loginRequest, + logout: authActions.logout +}; + +const connectedApp = connect(mapStateToProps, mapDispatchToProps)(App); +export default connectedApp; diff --git a/houston/src/components/Callback/Callback.js b/houston/src/components/Callback/Callback.js new file mode 100644 index 000000000..ea0e394f8 --- /dev/null +++ b/houston/src/components/Callback/Callback.js @@ -0,0 +1,40 @@ +import React, { Component } from 'react'; +import loading from './loading.svg'; +import { Redirect } from 'react-router-dom'; +import { connect } from 'react-redux'; + +class Callback extends Component { + render() { + if (this.props.loggedIn) { + return ; + } + + const style = { + position: 'absolute', + display: 'flex', + justifyContent: 'center', + height: '100vh', + width: '100vw', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: 'white', + }; + + return ( +
+ loading +
+ ); + } +} + +function mapStateToProps(state) { + const { loggedIn } = state.authentication; + return { + loggedIn + }; +}; + +export default connect(mapStateToProps)(Callback); diff --git a/houston/src/components/Callback/loading.svg b/houston/src/components/Callback/loading.svg new file mode 100644 index 000000000..74311d8fa --- /dev/null +++ b/houston/src/components/Callback/loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/houston/src/components/Notifications/NotificationEditor.js b/houston/src/components/Notifications/NotificationEditor.js new file mode 100644 index 000000000..e9650ace2 --- /dev/null +++ b/houston/src/components/Notifications/NotificationEditor.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Button, Form, FormGroup, Label, Input } from 'reactstrap'; +import _ from 'lodash'; + +import { notifyActions } from '../../actions/notifiy.actions'; + +function NotificationEditor(props) { + function onSubmit(e) { + e.preventDefault(); + props.sendNotificationToSubscriber(props.title, props.message); + } + const { email } = props; + return ( +
+ + + props.setNotificationTitle(e.target.value)} + placeholder="Enter title" + /> + + + + props.setNotificationMessage(e.target.value)} + placeholder="Enter message" + /> + + { + email && ( + + +
+
+ )} + +
+ ); +} + +NotificationEditor.propTypes = { + titleLabel: PropTypes.string.isRequired, + messageLabel: PropTypes.string.isRequired, + submitLabel: PropTypes.string.isRequired, +}; + +function mapStateToProps(state) { + let notification = state.notification; + const email = _.get(state, 'subscriber.email'); + return { + message: notification.message, + title: notification.title, + type: notification.type, + email + }; +} +const mapDispatchToProps = { + setNotificationMessage: notifyActions.setNotificationMessage, + setNotificationTitle: notifyActions.setNotificationTitle, + setNotificationType: notifyActions.setNotificationType, + sendNotificationToSubscriber: notifyActions.sendNotificationToSubscriber +} +export default connect(mapStateToProps, mapDispatchToProps)(NotificationEditor); diff --git a/houston/src/components/Notifications/Notifications.js b/houston/src/components/Notifications/Notifications.js new file mode 100644 index 000000000..f92ca848f --- /dev/null +++ b/houston/src/components/Notifications/Notifications.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Card, CardBody, CardTitle } from 'reactstrap'; + +import TextForm from './TextForm'; +import NotificationEditor from './NotificationEditor'; +import AlertMessage from '../Search/Alert'; + +class Notifications extends React.Component { + onSubmitEmail = (title, message) => { + //handle form processing here.... + console.log("Search On Submit"); + } + render() { + return ( +
+ + + + Notifications + + + +
+ + + Global Emails + + + +
+ ); + } +} + +Notifications.propTypes = { + loggedIn: PropTypes.bool, + pseudonym: PropTypes.object, + profile: PropTypes.object, +}; + +function mapStateToProps(state) { + const { subscriber } = state; + return { + profile: subscriber + }; +} + +export default connect(mapStateToProps)(Notifications); diff --git a/houston/src/components/Notifications/TextForm.js b/houston/src/components/Notifications/TextForm.js new file mode 100644 index 000000000..fa335f5c5 --- /dev/null +++ b/houston/src/components/Notifications/TextForm.js @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Form, FormGroup, Label, Input } from 'reactstrap'; + +function useFormInput(initialValue, submit) { + const [value, setValue] = useState(initialValue); + + function onChange(e) { + setValue(e.target.value); + } + + function onSubmit(e) { + e.preventDefault(); + submit(value); + } + + return { value, onChange, onSubmit }; +} + +export default function TextForm(props) { + const input = useFormInput('', props.onSubmit); + return ( +
+ + + + + +
+ ); +} + +TextForm.propTypes = { + inputLabel: PropTypes.string.isRequired, + submitLabel: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired +}; diff --git a/houston/src/components/Search/Alert.js b/houston/src/components/Search/Alert.js new file mode 100644 index 000000000..0c14f7a6f --- /dev/null +++ b/houston/src/components/Search/Alert.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Alert } from 'reactstrap'; + +import { alertActions } from '../../actions/alert.actions'; + +function AlertMessage(props) { + function onDismiss(e) { + props.clearAlert(); + } + + const visible = (props.alert && props.alert.type === 'alert-danger'); + if (!visible) { + return null + }; + return ( + + {props.alert.message} +
+
+ ); +} + +AlertMessage.propTypes = { + clearAlert: PropTypes.func.isRequired, + alert: PropTypes.shape({ + type: PropTypes.string, + message: PropTypes.string + }) +}; + +function mapStateToProps(state) { + const { alert } = state; + return { + alert + }; +} +const mapDispatchToProps = { + clearAlert: alertActions.clearAlert, +} +export default connect(mapStateToProps, mapDispatchToProps)(AlertMessage); diff --git a/houston/src/components/Search/DataUsage.js b/houston/src/components/Search/DataUsage.js new file mode 100644 index 000000000..d2c8a64e0 --- /dev/null +++ b/houston/src/components/Search/DataUsage.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Col, Row, Card, CardBody, CardTitle, Button } from 'reactstrap'; + +import WarningModal from '../Shared/WarningModal'; +import { humanReadableBytes } from '../../helpers'; + +class DataUsage extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + showWarning: false + }; + } + + handleCloseModal = () => { + this.setState({ + showWarning: false + }); + } + + handleShowModal = () => { + this.setState({ + showWarning: true + }); + } + + handleConfirmModal = () => { + this.handleCloseModal(); + // TODO call the method to give additional data + } + render() { + const modalHeading = `Confirm additional Data` + const modalText = `Do you really want to give this user an additional 1 GB of Data ?` + const props = this.props; + if (!props.balance) { + return null; + } + return ( + + + Data balance + + + {`Remaining ${props.balance}.`} + + + + + + + + + ); + } +} + +DataUsage.propTypes = { + loggedIn: PropTypes.bool, + pseudonym: PropTypes.object, + balance: PropTypes.string +}; + +function mapStateToProps(state) { + const { bundles } = state; + let balance = null; + if (Array.isArray(bundles)) { + balance = humanReadableBytes(bundles[0].balance); + } + return { + balance + }; +} + +export default connect(mapStateToProps)(DataUsage); diff --git a/houston/src/components/Search/PaymentHistory.js b/houston/src/components/Search/PaymentHistory.js new file mode 100644 index 000000000..7f6faca0c --- /dev/null +++ b/houston/src/components/Search/PaymentHistory.js @@ -0,0 +1,163 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Table, Card, CardBody, CardTitle, Button, UncontrolledTooltip } from 'reactstrap'; + +import { subscriberActions } from '../../actions/subscriber.actions'; +import { convertTimestampToDate } from '../../helpers'; +import WarningModal from '../Shared/WarningModal'; + + +export const RefundedItemOption = (props) => { + function nope(e) { + e.preventDefault(); + } + return ( + + + + {`Refunded on ${convertTimestampToDate(props.timestamp)}, ${props.reason}`} + + + ); +} + +export const FreeItemOption = props => (); +export const HistoryRow = props => { + const isRefunded = () => (props.item.refund && props.item.refund.id); + const isFreeProduct = () => (props.item.product.price.amount <= 0); + function onRefund(e) { + e.preventDefault(); + console.log(`Reverting ${props.item.id}`); + props.refundPurchase(props.item.id, 'requested_by_customer'); + } + + function renderOption() { + if (isRefunded()) { + return (); + } else if (isFreeProduct()) { + return (); + } else { + return ( + + ); + } + } + return ( + + {props.item.product.presentation.productLabel} + {props.item.product.presentation.priceLabel} + {convertTimestampToDate(props.item.timestamp)} + {renderOption()} + ); +} + +HistoryRow.propTypes = { + item: PropTypes.shape({ + id: PropTypes.string.isRequired, + product: PropTypes.shape({ + price: PropTypes.shape({ + amount: PropTypes.number.isRequired + }).isRequired, + presentation: PropTypes.shape({ + priceLabel: PropTypes.string, + productLabel: PropTypes.string + }).isRequired, + }), + refund: PropTypes.shape({ + id: PropTypes.string.isRequired, + reason: PropTypes.string, + timestamp: PropTypes.number.isRequired + }), + timestamp: PropTypes.number.isRequired + }), + refundPurchase: PropTypes.func.isRequired +}; + +class PaymentHistory extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + showConfirm: false + }; + } + handleCloseConfirm = () => { + const state = this.state; + state.showConfirm = false; + this.setState(state); + } + + handleShowConfirm = (id, reason) => { + const state = { ...this.state, id, reason }; + state.showConfirm = true; + this.setState(state); + } + + handleConfirm = () => { + this.handleCloseConfirm(); + const { id, reason } = this.state; + // TODO call the method to give additional data + console.log(`User confirmed, refunding id:${id}, reason:${reason}`); + this.props.refundPurchase(this.state.id, this.state.reason); + } + + render() { + const { props } = this; + if (!props.paymentHistory) { + return null; + } + const refundHeading = 'Confirm refund operartion'; + const refundText = 'Do you really want to refund this transaction ?'; + const listItems = props.paymentHistory.map((history) => + + ); + + return ( + + + Payment History + + + + + + + + + + + {listItems} + +
PlanPriceDateOptions
+
+ +
+ ); + } +} + +PaymentHistory.propTypes = { + loggedIn: PropTypes.bool, + paymentHistory: PropTypes.array, + refundPurchase: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + let paymentHistory = state.paymentHistory; + // Pass only arrays + if (!Array.isArray(paymentHistory)) { + paymentHistory = null; + } + return { + paymentHistory + }; +} +const mapDispatchToProps = { + refundPurchase: subscriberActions.refundPurchase +} +export default connect(mapStateToProps, mapDispatchToProps)(PaymentHistory); diff --git a/houston/src/components/Search/Profile.js b/houston/src/components/Search/Profile.js new file mode 100644 index 000000000..f80d68fa5 --- /dev/null +++ b/houston/src/components/Search/Profile.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Col, Row, Card, CardBody, CardTitle } from 'reactstrap'; + +import Subscription from './Subscription'; + +class Profile extends React.Component { + render() { + const props = this.props; + + let listItems = null; + if (Array.isArray(props.subscriptions.items)) { + listItems = props.subscriptions.items.map((subscription, index) => +
+ +
+
+ ); + } + return ( + + + User Profile + + {'Name:'} + {`${props.profile.nickname}`} + + + {'Email:'} + {`${props.profile.contactEmail}`} + + + {'Address:'} + {`${props.profile.address}`} + +
+ {listItems} +
+
+ ); + } +} + +Profile.propTypes = { + profile: PropTypes.shape({ + nickname: PropTypes.string, + contactEmail: PropTypes.string, + address: PropTypes.string + }), + subscriptions: PropTypes.shape({ + items: PropTypes.array, + }), +}; + +function mapStateToProps(state) { + const { subscriber } = state; + const { subscriptions } = state; + return { + profile: subscriber, + subscriptions + }; +} +export default connect(mapStateToProps)(Profile); diff --git a/houston/src/components/Search/Search.js b/houston/src/components/Search/Search.js new file mode 100644 index 000000000..def31a8db --- /dev/null +++ b/houston/src/components/Search/Search.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { subscriberActions } from '../../actions/subscriber.actions'; +import SearchForm from './SearchForm'; +import SearchResults from './SearchResults'; +import AlertMessage from './Alert'; + +class Search extends React.Component { + + onSubmit = (text) => { + this.props.getSubscriberAndBundlesByEmail(text); + } + + render() { + const hasResults = this.props.profile.nickname || false; + return ( +
+ + +
+ { + hasResults && ( + + ) + } +
+ ); + } +} + +Search.propTypes = { + loggedIn: PropTypes.bool, + pseudonym: PropTypes.object, + profile: PropTypes.object, +}; + +function mapStateToProps(state) { + const { loggedIn } = state.authentication; + const { subscriber } = state; + return { + loggedIn, + profile: subscriber + }; +}; + +const mapDispatchToProps = { + getSubscriberAndBundlesByEmail: subscriberActions.getSubscriberAndBundlesByEmail +}; +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/houston/src/components/Search/SearchForm.js b/houston/src/components/Search/SearchForm.js new file mode 100644 index 000000000..650e75d5a --- /dev/null +++ b/houston/src/components/Search/SearchForm.js @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Form, FormGroup, Label, Input } from 'reactstrap'; + +import { getTextType } from '../../helpers'; + +function useFormInput(initialValue, submit) { + const [value, setValue] = useState(initialValue); + + function onChange(e) { + setValue(e.target.value); + } + + function onSubmit(e) { + e.preventDefault(); + submit(value); + } + + function onValidateInput() { + const type = getTextType(value); + if (type === 'phonenumber' || type === 'email') { + return 'success' + }; + const length = value.length; + if (length > 5) { + return 'warning' + }; + return null; + } + + return { value, onChange, onSubmit, onValidateInput }; +} + +export default function SearchForm(props) { + const input = useFormInput('prasanth@redotter.sg', props.onSubmit) + return ( +
+
+ +
+ + +
+ +
+
+ ); +} + +SearchForm.propTypes = { + onSubmit: PropTypes.func.isRequired +}; diff --git a/houston/src/components/Search/SearchResults.js b/houston/src/components/Search/SearchResults.js new file mode 100644 index 000000000..b633fb0a0 --- /dev/null +++ b/houston/src/components/Search/SearchResults.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import DataUsage from "./DataUsage"; +import Profile from "./Profile"; +import PaymentHistory from "./PaymentHistory"; + +class SearchResults extends React.Component { + render() { + return ( +
+ +
+ +
+ +
+ ); + } +} + +export default SearchResults; diff --git a/houston/src/components/Search/Subscription.js b/houston/src/components/Search/Subscription.js new file mode 100644 index 000000000..afffb3618 --- /dev/null +++ b/houston/src/components/Search/Subscription.js @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row, Button } from 'reactstrap'; + +import WarningModal from '../Shared/WarningModal'; + +export default class Subscription extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + showBlock: false, + showNewSIM: false + }; + } + handleCloseBlock = () => { + const state = this.state; + state.showBlock = false; + this.setState(state); + } + + handleShowBlock = () => { + const state = this.state; + state.showBlock = true; + this.setState(state); + } + + handleConfirmBlock = () => { + this.handleCloseBlock(); + // TODO call the method to give additional data + } + + handleCloseNewSIM = () => { + const state = this.state; + state.showNewSIM = false; + this.setState(state); + } + + handleShowNewSIM = () => { + const state = this.state; + state.showNewSIM = true; + this.setState(state); + } + + handleConfirmNewSIM = () => { + this.handleCloseNewSIM(); + // TODO call the method to give additional data + } + + render() { + const blockHeading = 'Confirm Blocking of SIM'; + const blockText = 'Do you really want to block the current SIM card ? (Not implemented)'; + const newSIMHeading = 'Confirm new SIM'; + const newSIMText = 'Do you really want to provision new SIM card ? (Not implemented)'; + + const { subscription } = this.props; + return ( + <> + + {'Phone number:'} + {`${subscription.msisdn}`} + +
+ + + + + + + + + + + + ); + } +} + +Subscription.propTypes = { + subscription: PropTypes.shape({ + msisdn: PropTypes.string, + alias: PropTypes.string + }), +}; diff --git a/houston/src/components/Search/__tests__/PaymentHistory.js b/houston/src/components/Search/__tests__/PaymentHistory.js new file mode 100644 index 000000000..86bf288cd --- /dev/null +++ b/houston/src/components/Search/__tests__/PaymentHistory.js @@ -0,0 +1,89 @@ +import React from 'react'; +import { shallow, mount, render } from 'enzyme'; +import { Table, Card, CardBody, CardTitle, Button, UncontrolledTooltip } from 'reactstrap'; + +import { HistoryRow, FreeItemOption, RefundedItemOption } from '../PaymentHistory'; +import { convertTimestampToDate } from '../../../helpers'; + +it('renders history row with refund props', () => { + const props = { + item: { + id: 'id1', + product: { + price: { + amount: 10 + }, + presentation: { + priceLabel: "FreePrice", + productLabel: "FreeLabel" + }, + }, + refund: { + id: 'rid1', + timestamp: 1542802197874 + }, + timestamp: 1542802197874 + }, + refundPurchase: () => { } + }; + + const row = shallow(( + + )); + // console.log(row.debug()); // For showing the children + const date1 = convertTimestampToDate(props.item.timestamp); + const date2 = convertTimestampToDate(props.item.refund.timestamp); + expect(row.contains(FreePrice)).toEqual(true); + expect(row.contains(FreeLabel)).toEqual(true); + expect(row.contains({date1})).toEqual(true); + expect(row.contains({date2})).toEqual(true); + expect(row.contains()).toEqual(true); +}); + +it('renders history row with free props', () => { + const props = { + item: { + id: 'id1', + product: { + price: { + amount: 0 + }, + presentation: { + priceLabel: "FreePrice", + productLabel: "FreeLabel" + }, + }, + timestamp: 1542802197874 + }, + refundPurchase: () => { } + }; + + const row = shallow(( + + )); + expect(row.contains()).toEqual(true); +}); + +it('renders history row with props', () => { + const props = { + item: { + id: 'id1', + product: { + price: { + amount: 10 + }, + presentation: { + priceLabel: "FreePrice", + productLabel: "FreeLabel" + }, + }, + timestamp: 1542802197874 + }, + refundPurchase: () => { } + }; + + const row = shallow(( + + )); + expect(row.find(Button)).toHaveLength(1); +}); diff --git a/houston/src/components/Shared/WarningModal.js b/houston/src/components/Shared/WarningModal.js new file mode 100644 index 000000000..a323a1459 --- /dev/null +++ b/houston/src/components/Shared/WarningModal.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; + +const WarningModal = (props) => { + return ( + + {props.heading} + +

+ {props.warningText} +

+
+ + {' '} + + +
+ ); +}; + +WarningModal.propTypes = { + show: PropTypes.bool.isRequired, + heading: PropTypes.string.isRequired, + warningText: PropTypes.string.isRequired, + handleConfirm: PropTypes.func.isRequired, + handleClose: PropTypes.func.isRequired +}; + +export default WarningModal; diff --git a/houston/src/components/__tests__/App.js b/houston/src/components/__tests__/App.js new file mode 100644 index 000000000..31f8d1c91 --- /dev/null +++ b/houston/src/components/__tests__/App.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; + +import { store } from '../../helpers'; +import App from '../App'; + +export default function TestApp(props) { + return ( + + + + ); +} +it('renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/houston/src/helpers/__tests__/api.js b/houston/src/helpers/__tests__/api.js new file mode 100644 index 000000000..4a6d1ad2f --- /dev/null +++ b/houston/src/helpers/__tests__/api.js @@ -0,0 +1,69 @@ +import { createParams, transformError } from '../api'; + +it('creates REST parameters from object', () => { + const purchaseRecordId = 'ch_abcd'; + const reason = 'customer_requested'; + const expected = '?purchaseRecordId=ch_abcd&reason=customer_requested'; + const actual = createParams({ purchaseRecordId, reason }); + expect(actual).toEqual(expected); +}); + +it('creates REST parameters from undefined', () => { + const expected = ''; + const actual = createParams(); + expect(actual).toEqual(expected); +}); + +it('creates REST parameters from null', () => { + const expected = ''; + const actual = createParams(); + expect(actual).toEqual(expected); +}); + +it('creates REST parameters from empty object', () => { + const expected = ''; + const actual = createParams({}); + expect(actual).toEqual(expected); +}); + +it('transform Error object with array of errors', () => { + const errorObj = { errors: ['fail 1', 'fail 2'] }; + const expected = 'fail 1, fail 2'; + const actual = transformError(errorObj); + expect(actual).toEqual(expected); +}); + +it('transform Error object with errors', () => { + const errorObj = { errors: 'fail 1' }; + const expected = 'fail 1'; + const actual = transformError(errorObj); + expect(actual).toEqual(expected); +}); + +it('transform Error object with message property', () => { + const errorObj = { message: 'fail 1' }; + const expected = 'fail 1'; + const actual = transformError(errorObj); + expect(actual).toEqual(expected); +}); + +it('transform Error object with error property', () => { + const errorObj = { error: 'fail 1' }; + const expected = 'fail 1'; + const actual = transformError(errorObj); + expect(actual).toEqual(expected); +}); + +it('transform Error object without any properties', () => { + const errorObj = "fail 1"; + const expected = 'fail 1'; + const actual = transformError(errorObj); + expect(actual).toEqual(expected); +}); + +it('transform Error object without any properties', () => { + const errorObj = 1; + const expected = 'Something bad happened'; + const actual = transformError(errorObj); + expect(actual).toEqual(expected); +}); diff --git a/houston/src/helpers/api.js b/houston/src/helpers/api.js new file mode 100644 index 000000000..3d5e98f4b --- /dev/null +++ b/houston/src/helpers/api.js @@ -0,0 +1,139 @@ +import _ from 'lodash'; +import { getAPIRoot } from '../services/config-variables'; + +const API_ROOT = getAPIRoot(); + +let authHeaderResolver = null; +export function setAuthResolver(getterFunc) { + authHeaderResolver = getterFunc; +} + +export function createParams(params) { + const array = _.toPairs(params); + const kvParams = _.map(array, (kv) => { + return `${kv[0]}=${encodeURIComponent(kv[1])}`; + }); + return (array.length ? `?${kvParams.join('&')}` : ''); +} + + +const apiCaller = async (endpoint, method, body, allowEmptyResponse, params = []) => { + let fullUrl = (endpoint.indexOf(API_ROOT) === -1) ? API_ROOT + endpoint : endpoint; + + if (typeof params === 'object') { + fullUrl += createParams(params); + } else if (typeof params === 'string') { + fullUrl += params; + } + + //console.log('API URL:', fullUrl); + if (authHeaderResolver === null) { + console.log("apiCaller: authHeaderResolver not set"); + return Promise.reject(); + } + const auth = authHeaderResolver(); + if (auth.error) { + return Promise.reject(auth.error); + } + let options = { + method, + headers: { + Accept: 'application/json', + Authorization: auth.header + } + }; + if (body) { + options.body = body; + options.headers['content-type'] = 'application/json'; + } + //console.log('Calling', fullUrl, options); + return fetch(fullUrl, options) + .then(response => { + return response.text().then(text => { + //console.log(`Response text for -> ${fullUrl} ==> [${text}]`); + let json = null; + let exception = null; + // Capture any JSON parse exception. + try { + json = JSON.parse(text); + } catch (e) { + exception = e; + } + // Ignore exceptions for allowed empty responses + if (allowEmptyResponse && allowEmptyResponse === true) { + return {}; + } + // Rethrow any parse exceptions. + if (exception !== null) { + throw exception; + } + if (!response.ok) { + return Promise.reject(json); + } + return json; + }); + }); +}; + + +function transformError(errorObj) { + if (errorObj.errors) { + if (Array.isArray(errorObj.errors)) { + return errorObj.errors.join(', '); + } else { + return errorObj.errors.toString(); + } + } else if (errorObj.message) { + return errorObj.message.toString(); + } else if (errorObj.error) { + return errorObj.error.toString(); + } else if (typeof errorObj === 'string') { + return errorObj; + } else { + return 'Something bad happened'; + } +} + +// Action key that carries API call info interpreted by this Redux middleware. +export const CALL_API = 'Call API'; + +// A Redux middleware that interprets actions with CALL_API info specified. +// Performs the call and promises when such actions are dispatched. +export default (store) => (next) => (action) => { + const callAPI = action[CALL_API]; + if (typeof callAPI === 'undefined') { + return next(action); + } + + let { endpoint } = callAPI; + const { actions, method, body, allowEmptyResponse, params = [] } = callAPI; + + if (typeof endpoint === 'function') { + endpoint = endpoint(store.getState()); + } + + if (typeof endpoint !== 'string') { + throw new Error('Specify a string endpoint URL.'); + } + if (!Array.isArray(actions) || actions.length !== 3) { + throw new Error('Expected an array of three actions.'); + } + if (!actions.every((action) => typeof action === 'function')) { + throw new Error('Expected actions to be functions.'); + } + + const [request, success, failure] = actions; + next(request()); + + return apiCaller(endpoint, method, body, allowEmptyResponse, params).then( + (response) => next(success(response)), + (error) => { + next(failure({ + errorObj: error, + error: transformError(error) + })); + throw new Error(transformError(error)); + } + ); +} + diff --git a/houston/src/helpers/history.js b/houston/src/helpers/history.js new file mode 100644 index 000000000..629039e5a --- /dev/null +++ b/houston/src/helpers/history.js @@ -0,0 +1,3 @@ +import { createBrowserHistory } from 'history'; + +export const history = createBrowserHistory(); \ No newline at end of file diff --git a/houston/src/helpers/index.js b/houston/src/helpers/index.js new file mode 100644 index 000000000..de5ff96d5 --- /dev/null +++ b/houston/src/helpers/index.js @@ -0,0 +1,3 @@ +export * from './history'; +export * from './store'; +export * from './utils'; diff --git a/houston/src/helpers/store.js b/houston/src/helpers/store.js new file mode 100644 index 000000000..d85909411 --- /dev/null +++ b/houston/src/helpers/store.js @@ -0,0 +1,19 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import { createLogger } from 'redux-logger'; +import rootReducer from '../reducers'; +import api from './api'; +import { composeWithDevTools } from 'redux-devtools-extension'; +import { isChrome } from './utils'; + +const logger = createLogger(); + +let composeEnhancers = composeWithDevTools({ + // Specify name here, actionsBlacklist, actionsCreators and other options if needed +}); +if (!isChrome()) { + composeEnhancers = compose; +} +export const store = createStore(rootReducer, /* preloadedState, */ composeEnhancers( + applyMiddleware(thunk, api, logger) +)); diff --git a/houston/src/helpers/utils.js b/houston/src/helpers/utils.js new file mode 100644 index 000000000..fb471cd20 --- /dev/null +++ b/houston/src/helpers/utils.js @@ -0,0 +1,57 @@ +export const getTextType = (text) => { + const isPhoneNumber = /^[+]?\d+$/g.test(text); + const isEmail = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(text); + if (isPhoneNumber) return 'phonenumber'; + if (isEmail) return 'email'; + return 'unknown'; +} + +export const humanReadableBytes = (sizeInBytes) => { + var i = -1; + var byteUnits = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + do { + sizeInBytes = sizeInBytes / 1024; + i++; + } while (sizeInBytes > 1024); + return `${Math.max(sizeInBytes, 0.1).toFixed(1)} ${byteUnits[i]}`; +} + +export const convertTimestampToDate = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleString('en-GB', { timeZone: 'UTC' }); +} + +export const isChrome = () => { + // please note, + // that IE11 now returns undefined again for window.chrome + // and new Opera 30 outputs true for window.chrome + // but needs to check if window.opr is not undefined + // and new IE Edge outputs to true now for window.chrome + // and if not iOS Chrome check + // so use the below updated condition + var isChromium = window.chrome; + var winNav = window.navigator; + var vendorName = winNav.vendor; + var isOpera = typeof window.opr !== "undefined"; + var isIEedge = winNav.userAgent.indexOf("Edge") > -1; + var isIOSChrome = winNav.userAgent.match("CriOS"); + + if (isIOSChrome) { + // is Google Chrome on IOS + return false; + } else if ( + isChromium !== null && + typeof isChromium !== "undefined" && + vendorName === "Google Inc." && + isOpera === false && + isIEedge === false + ) { + // is Google Chrome + return true; + } else { + // not Google Chrome + return false; + } +} + +export const encodeEmail = (email) => (email ? encodeURIComponent(email) : email); diff --git a/houston/src/index.css b/houston/src/index.css new file mode 100644 index 000000000..3eb1f109b --- /dev/null +++ b/houston/src/index.css @@ -0,0 +1,24 @@ +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; +} + +* { + font-size: 14px; + line-height: 1.428; +} + +.alert { + position:fixed; + z-index: 999; +} diff --git a/houston/src/index.js b/houston/src/index.js new file mode 100644 index 000000000..0584bdff3 --- /dev/null +++ b/houston/src/index.js @@ -0,0 +1,14 @@ +import ReactDOM from 'react-dom'; +import * as serviceWorker from './serviceWorker'; +import 'bootstrap/dist/css/bootstrap.css'; +import './index.css'; + +import { makeMainRoutes } from './routes'; + +const routes = makeMainRoutes(); +ReactDOM.render(routes, document.getElementById('root')); + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: http://bit.ly/CRA-PWA +serviceWorker.unregister(); diff --git a/houston/src/logo.svg b/houston/src/logo.svg new file mode 100644 index 000000000..6b60c1042 --- /dev/null +++ b/houston/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/houston/src/reducers/auth.reducer.js b/houston/src/reducers/auth.reducer.js new file mode 100644 index 000000000..0d0599720 --- /dev/null +++ b/houston/src/reducers/auth.reducer.js @@ -0,0 +1,30 @@ +import { handleActions } from 'redux-actions'; + +import { authService } from '../services'; +import { authActions } from '../actions/auth.actions'; + +let user = JSON.parse(localStorage.getItem('user')); +const defaultState = user ? { loggedIn: true, user } : {}; + +const reducer = handleActions( + { + [authActions.loginRequest]: () => { + authService.login(); + return { loggingIn: true }; + }, + [authActions.loginSuccess]: (state, { payload }) => { + return { loggedIn: true, user: payload }; + }, + [authActions.logout]: () => { + authService.logout(); + return {}; + }, + [authActions.loginFailure]: () => { + authService.logout(); + return {}; + } + }, + defaultState +); + +export default reducer; diff --git a/houston/src/reducers/index.js b/houston/src/reducers/index.js new file mode 100644 index 000000000..a8c3b3b8b --- /dev/null +++ b/houston/src/reducers/index.js @@ -0,0 +1,50 @@ +import { combineReducers } from 'redux'; +import _ from 'lodash'; +import { authConstants, authActions } from '../actions/auth.actions'; +import { store } from '../helpers'; +import { subscriberConstants } from '../actions/subscriber.actions'; +import { notifyConstants } from '../actions/notifiy.actions'; + +// Reducers. +import alert from '../actions/alert.actions'; +import notification from '../actions/notifiy.actions'; +import authentication from './auth.reducer'; +import { subscriber, subscriptions, bundles, paymentHistory } from './subscriber.reducer'; + + +const appReducer = combineReducers({ + authentication, + alert, + notification, + subscriber, + subscriptions, + bundles, + paymentHistory +}); + +function checkForAuthenticationFailures(errorObj) { + if (errorObj && errorObj.code === authConstants.AUTHENTICATION_FAILURE) { + setTimeout(() => { + store.dispatch(authActions.logout()); + }); + } +} + +const rootReducer = (state, action) => { + if (action.type === authConstants.LOGOUT) { + state = {}; + } + switch (action.type) { + case authConstants.LOGIN_FAILURE: + case subscriberConstants.SUBSCRIPTIONS_FAILURE: + case subscriberConstants.SUBSCRIBER_BY_EMAIL_FAILURE: + case notifyConstants.NOTIFY_FAILURE: + checkForAuthenticationFailures(_.get(action, 'payload.errorObj')); + break; + default: + break; + } + return appReducer(state, action); +}; + +export default rootReducer; diff --git a/houston/src/reducers/subscriber.reducer.js b/houston/src/reducers/subscriber.reducer.js new file mode 100644 index 000000000..030335aa6 --- /dev/null +++ b/houston/src/reducers/subscriber.reducer.js @@ -0,0 +1,60 @@ +import { handleActions } from 'redux-actions' +import { actions } from '../actions/subscriber.actions'; + +const defaultState = {}; + +export const subscriber = handleActions( + { + [actions.subscriberByEmailRequest]: (state, action) => ({ + loading: true + }), + [actions.subscriberByEmailSuccess]: (state, action) => ({ + ...action.payload + }), + [actions.subscriberByEmailFailure]: (state, action) => ({ + ...action.payload + }) + }, + defaultState +); + +export const subscriptions = handleActions( + { + [actions.subscriptionsRequest]: (state, action) => ({ + loading: true + }), + [actions.subscriptionsSuccess]: (state, action) => ({ + items: action.payload + }), + [actions.subscriptionsFailure]: (state, action) => ({ + ...action.payload + }) + }, + defaultState +); + +export const bundles = handleActions( + { + [actions.bundlesRequest]: (state, action) => ({ + loading: true + }), + [actions.bundlesSuccess]: (state, action) => action.payload, + [actions.bundlesFailure]: (state, action) => ({ + ...action.payload + }) + }, + defaultState +); + +export const paymentHistory = handleActions( + { + [actions.paymentHistoryRequest]: (state, action) => ({ + loading: true + }), + [actions.paymentHistorySuccess]: (state, action) => action.payload, + [actions.paymentHistoryFailure]: (state, action) => ({ + ...action.payload + }) + }, + defaultState +); diff --git a/houston/src/routes.js b/houston/src/routes.js new file mode 100644 index 000000000..b44131e73 --- /dev/null +++ b/houston/src/routes.js @@ -0,0 +1,97 @@ +import React from 'react'; +import { Route, BrowserRouter as Router, Redirect, Switch, Link } from 'react-router-dom'; +import { connect } from 'react-redux'; + +import App from './components/App'; +import Search from './components/Search/Search'; +import Notifications from './components/Notifications/Notifications'; +import Callback from './components/Callback/Callback'; +import { Provider } from 'react-redux'; +import { store } from './helpers'; +import { authService } from './services'; + +const handleAuthentication = ({ location }) => { + if (/access_token|id_token|error/.test(location.hash)) { + authService.handleAuthentication(store.dispatch, location.hash); + } +}; + +function ProtectedRoute({ component: Component, ...rest }) { + return ( + { + return authService.isAuthenticated() ? ( + + ) : ( + + ); + } + } + /> + ); +} + +function login(props) { + // Redirect to search + if (props.loggedIn) { + return ; + } + return ( +
+

You are not logged in! Please Log In to continue.

+
+ ); +} + +function mapStateToProps(state) { + const { loggedIn } = state.authentication; + return { + loggedIn + }; +}; +const Login = connect(mapStateToProps)(login); + +function NoMatch() { + return ( +
+

No such path found here...

+
    +
  • + Search +
  • +
  • + Notifications +
  • +
+
+ ); +} + +export const makeMainRoutes = () => { + return ( + + +
+ + + + + + { + handleAuthentication(props); + return + }} /> + + +
+
+
+ ); +} + diff --git a/houston/src/serviceWorker.js b/houston/src/serviceWorker.js new file mode 100644 index 000000000..d2a3b6ba5 --- /dev/null +++ b/houston/src/serviceWorker.js @@ -0,0 +1,127 @@ +// In production, we register a service worker to serve assets from local cache. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on the "N+1" visit to a page, since previously +// cached resources are updated in the background. + +// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. +// This link also includes instructions on opting out of this behavior. + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export function register(config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://goo.gl/SC7cgQ' + ); + }); + } else { + // Is not local host. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the old content will have been purged and + // the fresh content will have been added to the cache. + // It's the perfect time to display a "New content is + // available; please refresh." message in your web app. + console.log('New content is available; please refresh.'); + + // Execute callback + if (config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl) + .then((response) => { + // Ensure service worker exists, and that we really are getting a JS file. + if ( + response.status === 404 || + response.headers.get('content-type').indexOf('javascript') === -1 + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + registration.unregister(); + }); + } +} diff --git a/houston/src/services/auth.service.js b/houston/src/services/auth.service.js new file mode 100644 index 000000000..276cd4f44 --- /dev/null +++ b/houston/src/services/auth.service.js @@ -0,0 +1,152 @@ +import auth0 from 'auth0-js'; +import _ from 'lodash'; + +import { getAuthConfig } from './config-variables'; +import { authConstants, authActions } from '../actions/auth.actions'; +import { store } from '../helpers'; +import { setAuthResolver } from '../helpers/api'; + +const authConfig = getAuthConfig(); +class Auth { + webAuth = new auth0.WebAuth({ + domain: authConfig.domain, + clientID: authConfig.clientId, + redirectUri: authConfig.callbackUrl, + responseType: 'token id_token', + scope: 'openid profile email', + audience: 'http://google_api' + }); + + constructor() { + this.user = null; + setAuthResolver(this.getHeader); + this.loadCurrentSession(); + } + + login() { + this.webAuth.authorize(); + } + + handleAuthentication(dispatch, hash) { + if (this.user != null) { + console.log('Valid session in memory, callback is called before the session is cleared.'); + return; + } + this.user = {}; // initialize + this.webAuth.parseHash({ hash }, (err, authResult) => { + if (authResult && authResult.accessToken) { + this.setSession(authResult); + // navigate to the home route + dispatch(authActions.loginSuccess(this.user)); + } else if (err) { + console.log('Error recieved from auth0 parse', err); + this.clearLocalStorage(); + if (err.error === 'invalid_token') { + // Token expired, re-login + console.log('Invalid token recieved, possibly expired token'); + } + dispatch(authActions.loginFailure(err)); + } + }); + } + + setSession(authResult) { + // Set the time that the access token will expire at + let expiresAt = JSON.stringify((authResult.expiresIn * 1000) + new Date().getTime()); + const { accessToken } = authResult; + localStorage.setItem('access_token', accessToken); + localStorage.setItem('expires_at', expiresAt); + + const name = _.get(authResult, 'idTokenPayload.name'); + const email = _.get(authResult, 'idTokenPayload.email'); + const picture = _.get(authResult, 'idTokenPayload.picture'); + localStorage.setItem('name', name); + localStorage.setItem('email', email); + localStorage.setItem('picture', picture); + + this.user = { accessToken, expiresAt, name, email, picture }; + } + + loadCurrentSession = () => { + if (this.user !== null) { + console.log('Valid session in memory, clear this before calling loadCurrentSession'); + return; + } + const accessToken = localStorage.getItem('access_token'); + if (accessToken === null) { + console.log('No saved user'); + return; + } + const expiresAt = localStorage.getItem('expires_at'); + if (!this.isTokenValid(expiresAt)) { + console.log('Expired Token, clear local storage'); + this.clearLocalStorage(); + return; + } + const name = localStorage.getItem('name'); + const email = localStorage.getItem('email'); + const picture = localStorage.getItem('picture'); + this.user = { accessToken, expiresAt, name, email, picture }; + setTimeout(() => store.dispatch(authActions.loginSuccess(this.user))); + } + + logout() { + this.clearLocalStorage(); + // navigate to the home route + this.webAuth.logout({returnTo: authConfig.homeUrl}); + } + + clearLocalStorage() { + this.user = null; + // Clear access token and ID token from local storage + localStorage.removeItem('access_token'); + localStorage.removeItem('expires_at'); + localStorage.removeItem('name'); + localStorage.removeItem('email'); + localStorage.removeItem('picture'); + } + + isTokenValid(expiresAt) { + // Check whether the current time is past the + // access token's expiry time + let expiry = JSON.parse(expiresAt); + return new Date().getTime() < expiry; + } + + authHeader() { + const user = _.get(store.getState(), "authentication.user"); + if (user && this.isTokenValid(user.expiresAt)) { + return `Bearer ${user.accessToken}`; + } + return null; + } + + getHeader = () => { + const header = this.authHeader(); + if (!header) { + console.log("apiCaller: Authentication failed"); + const error = { + code: authConstants.AUTHENTICATION_FAILURE, + message: "Authentication failed" + }; + return { error }; + } + return { header }; + } + + isAuthenticated() { + const user = _.get(store.getState(), "authentication.user"); + if (user) { + if (this.isTokenValid(user.expiresAt)) { + return true; + } else { + console.log('Token expired, dispatch-->logout'); + setTimeout(() => store.dispatch(authActions.logout())); + } + } + return false; + } + +} +const auth = new Auth(); +export const authService = auth; diff --git a/houston/src/services/config-variables.js b/houston/src/services/config-variables.js new file mode 100644 index 000000000..ca519fd96 --- /dev/null +++ b/houston/src/services/config-variables.js @@ -0,0 +1,35 @@ + +const DEV_AUTH_CONFIG = { + domain: 'redotter-admin-dev.eu.auth0.com', + clientId: '9DgdUDakjmn3O00NkDKna0YAsZanYqof', + callbackUrl: 'http://localhost:3000/callback', + homeUrl: 'http://localhost:3000' +}; + +const DEPLOYED_DEV_AUTH_CONFIG = { + domain: 'redotter-admin-dev.eu.auth0.com', + clientId: '9DgdUDakjmn3O00NkDKna0YAsZanYqof', + callbackUrl: 'https://redotter-admin-dev.firebaseapp.com/callback', + homeUrl: 'https://redotter-admin-dev.firebaseapp.com' +}; + +export function getAuthConfig() { + if (process.env.REACT_APP_DEPLOYMENT_ENV === "development") { + return DEPLOYED_DEV_AUTH_CONFIG; + } else if (process.env.NODE_ENV === "development") { + return DEV_AUTH_CONFIG; + } else { + return DEV_AUTH_CONFIG; + } +} + +export function getAPIRoot() { + const API_ROOT = 'https://houston-api.dev.oya.world/'; + if (process.env.REACT_APP_DEPLOYMENT_ENV === "development") { + return API_ROOT; + } else if (process.env.NODE_ENV === "development") { + return API_ROOT; + } else { + return API_ROOT; + } +} diff --git a/houston/src/services/index.js b/houston/src/services/index.js new file mode 100644 index 000000000..2a719d159 --- /dev/null +++ b/houston/src/services/index.js @@ -0,0 +1 @@ +export * from './auth.service'; diff --git a/houston/src/setupTests.js b/houston/src/setupTests.js new file mode 100644 index 000000000..231862860 --- /dev/null +++ b/houston/src/setupTests.js @@ -0,0 +1,5 @@ +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import 'jest-enzyme'; + +configure({ adapter: new Adapter() }); diff --git a/houston/yarn.lock b/houston/yarn.lock new file mode 100644 index 000000000..c31a1edee --- /dev/null +++ b/houston/yarn.lock @@ -0,0 +1,11267 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@7.0.0", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.0.0-beta.35": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" + integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA== + dependencies: + "@babel/highlight" "^7.0.0" + +"@babel/core@7.2.2": + version "7.2.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.2.2.tgz#07adba6dde27bb5ad8d8672f15fde3e08184a687" + integrity sha512-59vB0RWt09cAct5EIe58+NzGP4TFSD3Bz//2/ELy3ZeTeKF6VTD1AXlH8BGGbCX0PuobZBsIzO7IAI9PH67eKw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.2.2" + "@babel/helpers" "^7.2.0" + "@babel/parser" "^7.2.2" + "@babel/template" "^7.2.2" + "@babel/traverse" "^7.2.2" + "@babel/types" "^7.2.2" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.10" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/core@^7.1.0", "@babel/core@^7.1.6", "@babel/core@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.4.3.tgz#198d6d3af4567be3989550d97e068de94503074f" + integrity sha512-oDpASqKFlbspQfzAE7yaeTmdljSH2ADIvBlb0RwbStltTuWa0+7CCI1fYVINNv9saHPa1W7oaKeuNuKj+RQCvA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.4.0" + "@babel/helpers" "^7.4.3" + "@babel/parser" "^7.4.3" + "@babel/template" "^7.4.0" + "@babel/traverse" "^7.4.3" + "@babel/types" "^7.4.0" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.11" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.0.0", "@babel/generator@^7.2.2", "@babel/generator@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.4.0.tgz#c230e79589ae7a729fd4631b9ded4dc220418196" + integrity sha512-/v5I+a1jhGSKLgZDcmAUZ4K/VePi43eRkUs3yePW1HB1iANOD5tqJXwGSG4BZhSksP8J9ejSlwGeTiiOFZOrXQ== + dependencies: + "@babel/types" "^7.4.0" + jsesc "^2.5.1" + lodash "^4.17.11" + source-map "^0.5.0" + trim-right "^1.0.1" + +"@babel/helper-annotate-as-pure@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32" + integrity sha512-3UYcJUj9kvSLbLbUIfQTqzcy5VX7GRZ/CCDrnOaZorFFM01aXp1+GJwuFGV4NDDoAS+mOUyHcO6UD/RfqOks3Q== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.1.0.tgz#6b69628dfe4087798e0c4ed98e3d4a6b2fbd2f5f" + integrity sha512-qNSR4jrmJ8M1VMM9tibvyRAHXQs2PmaksQF7c1CGJNipfe3D8p+wgNwgso/P2A2r2mdgBWAXljNWR0QRZAMW8w== + dependencies: + "@babel/helper-explode-assignable-expression" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-builder-react-jsx@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.3.0.tgz#a1ac95a5d2b3e88ae5e54846bf462eeb81b318a4" + integrity sha512-MjA9KgwCuPEkQd9ncSXvSyJ5y+j2sICHyrI0M3L+6fnS4wMSNDc1ARXsbTfbb2cXHn17VisSnU/sHFTCxVxSMw== + dependencies: + "@babel/types" "^7.3.0" + esutils "^2.0.0" + +"@babel/helper-call-delegate@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.4.0.tgz#f308eabe0d44f451217853aedf4dea5f6fe3294f" + integrity sha512-SdqDfbVdNQCBp3WhK2mNdDvHd3BD6qbmIc43CAyjnsfCmgHMeqgDcM3BzY2lchi7HBJGJ2CVdynLWbezaE4mmQ== + dependencies: + "@babel/helper-hoist-variables" "^7.4.0" + "@babel/traverse" "^7.4.0" + "@babel/types" "^7.4.0" + +"@babel/helper-create-class-features-plugin@^7.3.0": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.4.3.tgz#5bbd279c6c3ac6a60266b89bbfe7f8021080a1ef" + integrity sha512-UMl3TSpX11PuODYdWGrUeW6zFkdYhDn7wRLrOuNVM6f9L+S9CzmDXYyrp3MTHcwWjnzur1f/Op8A7iYZWya2Yg== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-member-expression-to-functions" "^7.0.0" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.4.0" + "@babel/helper-split-export-declaration" "^7.4.0" + +"@babel/helper-define-map@^7.1.0", "@babel/helper-define-map@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.4.0.tgz#cbfd8c1b2f12708e262c26f600cd16ed6a3bc6c9" + integrity sha512-wAhQ9HdnLIywERVcSvX40CEJwKdAa1ID4neI9NXQPDOHwwA+57DqwLiPEVy2AIyWzAk0CQ8qx4awO0VUURwLtA== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/types" "^7.4.0" + lodash "^4.17.11" + +"@babel/helper-explode-assignable-expression@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.1.0.tgz#537fa13f6f1674df745b0c00ec8fe4e99681c8f6" + integrity sha512-NRQpfHrJ1msCHtKjbzs9YcMmJZOg6mQMmGRB+hbamEdG5PNpaSm95275VD92DvJKuyl0s2sFiDmMZ+EnnvufqA== + dependencies: + "@babel/traverse" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-function-name@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz#a0ceb01685f73355d4360c1247f582bfafc8ff53" + integrity sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw== + dependencies: + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/template" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-get-function-arity@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3" + integrity sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-hoist-variables@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.4.0.tgz#25b621399ae229869329730a62015bbeb0a6fbd6" + integrity sha512-/NErCuoe/et17IlAQFKWM24qtyYYie7sFIrW/tIQXpck6vAu2hhtYYsKLBWQV+BQZMbcIYPU/QMYuTufrY4aQw== + dependencies: + "@babel/types" "^7.4.0" + +"@babel/helper-member-expression-to-functions@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz#8cd14b0a0df7ff00f009e7d7a436945f47c7a16f" + integrity sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-module-imports@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz#96081b7111e486da4d2cd971ad1a4fe216cc2e3d" + integrity sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-module-transforms@^7.1.0", "@babel/helper-module-transforms@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.4.3.tgz#b1e357a1c49e58a47211a6853abb8e2aaefeb064" + integrity sha512-H88T9IySZW25anu5uqyaC1DaQre7ofM+joZtAaO2F8NBdFfupH0SZ4gKjgSFVcvtx/aAirqA9L9Clio2heYbZA== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-simple-access" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.0.0" + "@babel/template" "^7.2.2" + "@babel/types" "^7.2.2" + lodash "^4.17.11" + +"@babel/helper-optimise-call-expression@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.0.0.tgz#a2920c5702b073c15de51106200aa8cad20497d5" + integrity sha512-u8nd9NQePYNQV8iPWu/pLLYBqZBa4ZaY1YWRFMuxrid94wKI1QNt67NEZ7GAe5Kc/0LLScbim05xZFWkAdrj9g== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-plugin-utils@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250" + integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA== + +"@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.4.3.tgz#9d6e5428bfd638ab53b37ae4ec8caf0477495147" + integrity sha512-hnoq5u96pLCfgjXuj8ZLX3QQ+6nAulS+zSgi6HulUwFbEruRAKwbGLU5OvXkE14L8XW6XsQEKsIDfgthKLRAyA== + dependencies: + lodash "^4.17.11" + +"@babel/helper-remap-async-to-generator@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.1.0.tgz#361d80821b6f38da75bd3f0785ece20a88c5fe7f" + integrity sha512-3fOK0L+Fdlg8S5al8u/hWE6vhufGSn0bN09xm2LXMy//REAF8kDCrYoOBKYmA8m5Nom+sV9LyLCwrFynA8/slg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-wrap-function" "^7.1.0" + "@babel/template" "^7.1.0" + "@babel/traverse" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-replace-supers@^7.1.0", "@babel/helper-replace-supers@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.4.0.tgz#4f56adb6aedcd449d2da9399c2dcf0545463b64c" + integrity sha512-PVwCVnWWAgnal+kJ+ZSAphzyl58XrFeSKSAJRiqg5QToTsjL+Xu1f9+RJ+d+Q0aPhPfBGaYfkox66k86thxNSg== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.0.0" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/traverse" "^7.4.0" + "@babel/types" "^7.4.0" + +"@babel/helper-simple-access@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz#65eeb954c8c245beaa4e859da6188f39d71e585c" + integrity sha512-Vk+78hNjRbsiu49zAPALxTb+JUQCz1aolpd8osOF16BGnLtseD21nbHgLPGUwrXEurZgiCOUmvs3ExTu4F5x6w== + dependencies: + "@babel/template" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-split-export-declaration@^7.0.0", "@babel/helper-split-export-declaration@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.0.tgz#571bfd52701f492920d63b7f735030e9a3e10b55" + integrity sha512-7Cuc6JZiYShaZnybDmfwhY4UYHzI6rlqhWjaIqbsJGsIqPimEYy5uh3akSRLMg65LSdSEnJ8a8/bWQN6u2oMGw== + dependencies: + "@babel/types" "^7.4.0" + +"@babel/helper-wrap-function@^7.1.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa" + integrity sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/template" "^7.1.0" + "@babel/traverse" "^7.1.0" + "@babel/types" "^7.2.0" + +"@babel/helpers@^7.2.0", "@babel/helpers@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.4.3.tgz#7b1d354363494b31cb9a2417ae86af32b7853a3b" + integrity sha512-BMh7X0oZqb36CfyhvtbSmcWc3GXocfxv3yNsAEuM0l+fAqSO22rQrUpijr3oE/10jCTrB6/0b9kzmG4VetCj8Q== + dependencies: + "@babel/template" "^7.4.0" + "@babel/traverse" "^7.4.3" + "@babel/types" "^7.4.0" + +"@babel/highlight@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" + integrity sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw== + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + +"@babel/parser@^7.0.0", "@babel/parser@^7.2.2", "@babel/parser@^7.4.0", "@babel/parser@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.4.3.tgz#eb3ac80f64aa101c907d4ce5406360fe75b7895b" + integrity sha512-gxpEUhTS1sGA63EGQGuA+WESPR/6tz6ng7tSHFCmaTJK/cGK8y37cBTspX+U2xCAue2IQVvF6Z0oigmjwD8YGQ== + +"@babel/plugin-proposal-async-generator-functions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz#b289b306669dce4ad20b0252889a15768c9d417e" + integrity sha512-+Dfo/SCQqrwx48ptLVGLdE39YtWRuKc/Y9I5Fy0P1DDBB9lsAHpjcEJQt+4IifuSOSTLBKJObJqMvaO1pIE8LQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-remap-async-to-generator" "^7.1.0" + "@babel/plugin-syntax-async-generators" "^7.2.0" + +"@babel/plugin-proposal-class-properties@7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.3.0.tgz#272636bc0fa19a0bc46e601ec78136a173ea36cd" + integrity sha512-wNHxLkEKTQ2ay0tnsam2z7fGZUi+05ziDJflEt3AZTP3oXLKHJp9HqhfroB/vdMvt3sda9fAbq7FsG8QPDrZBg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.3.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-proposal-decorators@7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.3.0.tgz#637ba075fa780b1f75d08186e8fb4357d03a72a7" + integrity sha512-3W/oCUmsO43FmZIqermmq6TKaRSYhmh/vybPfVFwQWdSb8xwki38uAIvknCRzuyHRuYfCYmJzL9or1v0AffPjg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.3.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-decorators" "^7.2.0" + +"@babel/plugin-proposal-json-strings@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz#568ecc446c6148ae6b267f02551130891e29f317" + integrity sha512-MAFV1CA/YVmYwZG0fBQyXhmj0BHCB5egZHCKWIFVv/XCxAeVGIHfos3SwDck4LvCllENIAg7xMKOG5kH0dzyUg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-json-strings" "^7.2.0" + +"@babel/plugin-proposal-object-rest-spread@7.3.2": + version "7.3.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.3.2.tgz#6d1859882d4d778578e41f82cc5d7bf3d5daf6c1" + integrity sha512-DjeMS+J2+lpANkYLLO+m6GjoTMygYglKmRe6cDTbFv3L9i6mmiE8fe6B8MtCSLZpVXscD5kn7s6SgtHrDoBWoA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-object-rest-spread" "^7.2.0" + +"@babel/plugin-proposal-object-rest-spread@^7.3.1", "@babel/plugin-proposal-object-rest-spread@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.4.3.tgz#be27cd416eceeba84141305b93c282f5de23bbb4" + integrity sha512-xC//6DNSSHVjq8O2ge0dyYlhshsH4T7XdCVoxbi5HzLYWfsC5ooFlJjrXk8RcAT+hjHAK9UjBXdylzSoDK3t4g== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-object-rest-spread" "^7.2.0" + +"@babel/plugin-proposal-optional-catch-binding@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.2.0.tgz#135d81edb68a081e55e56ec48541ece8065c38f5" + integrity sha512-mgYj3jCcxug6KUcX4OBoOJz3CMrwRfQELPQ5560F70YQUBZB7uac9fqaWamKR1iWUzGiK2t0ygzjTScZnVz75g== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" + +"@babel/plugin-proposal-unicode-property-regex@^7.2.0", "@babel/plugin-proposal-unicode-property-regex@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.4.0.tgz#202d91ee977d760ef83f4f416b280d568be84623" + integrity sha512-h/KjEZ3nK9wv1P1FSNb9G079jXrNYR0Ko+7XkOx85+gM24iZbPn0rh4vCftk+5QKY7y1uByFataBTmX7irEF1w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.0.0" + regexpu-core "^4.5.4" + +"@babel/plugin-syntax-async-generators@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.2.0.tgz#69e1f0db34c6f5a0cf7e2b3323bf159a76c8cb7f" + integrity sha512-1ZrIRBv2t0GSlcwVoQ6VgSLpLgiN/FVQUzt9znxo7v2Ov4jJrs8RY8tv0wvDmFN3qIdMKWrmMMW6yZ0G19MfGg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-decorators@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.2.0.tgz#c50b1b957dcc69e4b1127b65e1c33eef61570c1b" + integrity sha512-38QdqVoXdHUQfTpZo3rQwqQdWtCn5tMv4uV6r2RMfTqNBuv4ZBhz79SfaQWKTVmxHjeFv/DnXVC/+agHCklYWA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-dynamic-import@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz#69c159ffaf4998122161ad8ebc5e6d1f55df8612" + integrity sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-flow@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.2.0.tgz#a765f061f803bc48f240c26f8747faf97c26bf7c" + integrity sha512-r6YMuZDWLtLlu0kqIim5o/3TNRAlWb073HwT3e2nKf9I8IIvOggPrnILYPsrrKilmn/mYEMCf/Z07w3yQJF6dg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-json-strings@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz#72bd13f6ffe1d25938129d2a186b11fd62951470" + integrity sha512-5UGYnMSLRE1dqqZwug+1LISpA403HzlSfsg6P9VXU6TBjcSHeNlw4DxDx7LgpF+iKZoOG/+uzqoRHTdcUpiZNg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-jsx@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz#0b85a3b4bc7cdf4cc4b8bf236335b907ca22e7c7" + integrity sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-object-rest-spread@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz#3b7a3e733510c57e820b9142a6579ac8b0dfad2e" + integrity sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.2.0.tgz#a94013d6eda8908dfe6a477e7f9eda85656ecf5c" + integrity sha512-bDe4xKNhb0LI7IvZHiA13kff0KEfaGX/Hv4lMA9+7TEc63hMNvfKo6ZFpXhKuEp+II/q35Gc4NoMeDZyaUbj9w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-typescript@^7.2.0": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.3.3.tgz#a7cc3f66119a9f7ebe2de5383cce193473d65991" + integrity sha512-dGwbSMA1YhVS8+31CnPR7LB4pcbrzcV99wQzby4uAfrkZPYZlQ7ImwdpzLqi6Z6IL02b8IAL379CaMwo0x5Lag== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-arrow-functions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550" + integrity sha512-ER77Cax1+8/8jCB9fo4Ud161OZzWN5qawi4GusDuRLcDbDG+bIGYY20zb2dfAFdTRGzrfq2xZPvF0R64EHnimg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-async-to-generator@^7.2.0", "@babel/plugin-transform-async-to-generator@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.4.0.tgz#234fe3e458dce95865c0d152d256119b237834b0" + integrity sha512-EeaFdCeUULM+GPFEsf7pFcNSxM7hYjoj5fiYbyuiXobW4JhFnjAv9OWzNwHyHcKoPNpAfeRDuW6VyaXEDUBa7g== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-remap-async-to-generator" "^7.1.0" + +"@babel/plugin-transform-block-scoped-functions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.2.0.tgz#5d3cc11e8d5ddd752aa64c9148d0db6cb79fd190" + integrity sha512-ntQPR6q1/NKuphly49+QiQiTN0O63uOwjdD6dhIjSWBI5xlrbUFh720TIpzBhpnrLfv2tNH/BXvLIab1+BAI0w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-block-scoping@^7.2.0", "@babel/plugin-transform-block-scoping@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.4.0.tgz#164df3bb41e3deb954c4ca32ffa9fcaa56d30bcb" + integrity sha512-AWyt3k+fBXQqt2qb9r97tn3iBwFpiv9xdAiG+Gr2HpAZpuayvbL55yWrsV3MyHvXk/4vmSiedhDRl1YI2Iy5nQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + lodash "^4.17.11" + +"@babel/plugin-transform-classes@7.2.2": + version "7.2.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.2.2.tgz#6c90542f210ee975aa2aa8c8b5af7fa73a126953" + integrity sha512-gEZvgTy1VtcDOaQty1l10T3jQmJKlNVxLDCs+3rCVPr6nMkODLELxViq5X9l+rfxbie3XrfrMCYYY6eX3aOcOQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-define-map" "^7.1.0" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.0.0" + globals "^11.1.0" + +"@babel/plugin-transform-classes@^7.2.0", "@babel/plugin-transform-classes@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.4.3.tgz#adc7a1137ab4287a555d429cc56ecde8f40c062c" + integrity sha512-PUaIKyFUDtG6jF5DUJOfkBdwAS/kFFV3XFk7Nn0a6vR7ZT8jYw5cGtIlat77wcnd0C6ViGqo/wyNf4ZHytF/nQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-define-map" "^7.4.0" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.4.0" + "@babel/helper-split-export-declaration" "^7.4.0" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.2.0.tgz#83a7df6a658865b1c8f641d510c6f3af220216da" + integrity sha512-kP/drqTxY6Xt3NNpKiMomfgkNn4o7+vKxK2DDKcBG9sHj51vHqMBGy8wbDS/J4lMxnqs153/T3+DmCEAkC5cpA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-destructuring@7.3.2": + version "7.3.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.3.2.tgz#f2f5520be055ba1c38c41c0e094d8a461dd78f2d" + integrity sha512-Lrj/u53Ufqxl/sGxyjsJ2XNtNuEjDyjpqdhMNh5aZ+XFOdThL46KBj27Uem4ggoezSYBxKWAil6Hu8HtwqesYw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-destructuring@^7.2.0", "@babel/plugin-transform-destructuring@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.4.3.tgz#1a95f5ca2bf2f91ef0648d5de38a8d472da4350f" + integrity sha512-rVTLLZpydDFDyN4qnXdzwoVpk1oaXHIvPEOkOLyr88o7oHxVc/LyrnDx+amuBWGOwUb7D1s/uLsKBNTx08htZg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-dotall-regex@^7.2.0", "@babel/plugin-transform-dotall-regex@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.4.3.tgz#fceff1c16d00c53d32d980448606f812cd6d02bf" + integrity sha512-9Arc2I0AGynzXRR/oPdSALv3k0rM38IMFyto7kOCwb5F9sLUt2Ykdo3V9yUPR+Bgr4kb6bVEyLkPEiBhzcTeoA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.4.3" + regexpu-core "^4.5.4" + +"@babel/plugin-transform-duplicate-keys@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.2.0.tgz#d952c4930f312a4dbfff18f0b2914e60c35530b3" + integrity sha512-q+yuxW4DsTjNceUiTzK0L+AfQ0zD9rWaTLiUqHA8p0gxx7lu1EylenfzjeIWNkPy6e/0VG/Wjw9uf9LueQwLOw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-exponentiation-operator@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.2.0.tgz#a63868289e5b4007f7054d46491af51435766008" + integrity sha512-umh4hR6N7mu4Elq9GG8TOu9M0bakvlsREEC+ialrQN6ABS4oDQ69qJv1VtR3uxlKMCQMCvzk7vr17RHKcjx68A== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-flow-strip-types@7.2.3": + version "7.2.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.2.3.tgz#e3ac2a594948454e7431c7db33e1d02d51b5cd69" + integrity sha512-xnt7UIk9GYZRitqCnsVMjQK1O2eKZwFB3CvvHjf5SGx6K6vr/MScCKQDnf1DxRaj501e3pXjti+inbSXX2ZUoQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-flow" "^7.2.0" + +"@babel/plugin-transform-for-of@^7.2.0", "@babel/plugin-transform-for-of@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.3.tgz#c36ff40d893f2b8352202a2558824f70cd75e9fe" + integrity sha512-UselcZPwVWNSURnqcfpnxtMehrb8wjXYOimlYQPBnup/Zld426YzIhNEvuRsEWVHfESIECGrxoI6L5QqzuLH5Q== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-function-name@^7.2.0", "@babel/plugin-transform-function-name@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.4.3.tgz#130c27ec7fb4f0cba30e958989449e5ec8d22bbd" + integrity sha512-uT5J/3qI/8vACBR9I1GlAuU/JqBtWdfCrynuOkrWG6nCDieZd5przB1vfP59FRHBZQ9DC2IUfqr/xKqzOD5x0A== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-literals@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.2.0.tgz#690353e81f9267dad4fd8cfd77eafa86aba53ea1" + integrity sha512-2ThDhm4lI4oV7fVQ6pNNK+sx+c/GM5/SaML0w/r4ZB7sAneD/piDJtwdKlNckXeyGK7wlwg2E2w33C/Hh+VFCg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-member-expression-literals@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.2.0.tgz#fa10aa5c58a2cb6afcf2c9ffa8cb4d8b3d489a2d" + integrity sha512-HiU3zKkSU6scTidmnFJ0bMX8hz5ixC93b4MHMiYebmk2lUVNGOboPsqQvx5LzooihijUoLR/v7Nc1rbBtnc7FA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-modules-amd@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.2.0.tgz#82a9bce45b95441f617a24011dc89d12da7f4ee6" + integrity sha512-mK2A8ucqz1qhrdqjS9VMIDfIvvT2thrEsIQzbaTdc5QFzhDjQv2CkJJ5f6BXIkgbmaoax3zBr2RyvV/8zeoUZw== + dependencies: + "@babel/helper-module-transforms" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-modules-commonjs@^7.2.0", "@babel/plugin-transform-modules-commonjs@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.4.3.tgz#3917f260463ac08f8896aa5bd54403f6e1fed165" + integrity sha512-sMP4JqOTbMJMimqsSZwYWsMjppD+KRyDIUVW91pd7td0dZKAvPmhCaxhOzkzLParKwgQc7bdL9UNv+rpJB0HfA== + dependencies: + "@babel/helper-module-transforms" "^7.4.3" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-simple-access" "^7.1.0" + +"@babel/plugin-transform-modules-systemjs@^7.2.0", "@babel/plugin-transform-modules-systemjs@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.4.0.tgz#c2495e55528135797bc816f5d50f851698c586a1" + integrity sha512-gjPdHmqiNhVoBqus5qK60mWPp1CmYWp/tkh11mvb0rrys01HycEGD7NvvSoKXlWEfSM9TcL36CpsK8ElsADptQ== + dependencies: + "@babel/helper-hoist-variables" "^7.4.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-modules-umd@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.2.0.tgz#7678ce75169f0877b8eb2235538c074268dd01ae" + integrity sha512-BV3bw6MyUH1iIsGhXlOK6sXhmSarZjtJ/vMiD9dNmpY8QXFFQTj+6v92pcfy1iqa8DeAfJFwoxcrS/TUZda6sw== + dependencies: + "@babel/helper-module-transforms" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.3.0", "@babel/plugin-transform-named-capturing-groups-regex@^7.4.2": + version "7.4.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.4.2.tgz#800391136d6cbcc80728dbdba3c1c6e46f86c12e" + integrity sha512-NsAuliSwkL3WO2dzWTOL1oZJHm0TM8ZY8ZSxk2ANyKkt5SQlToGA4pzctmq1BEjoacurdwZ3xp2dCQWJkME0gQ== + dependencies: + regexp-tree "^0.1.0" + +"@babel/plugin-transform-new-target@^7.0.0", "@babel/plugin-transform-new-target@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.4.0.tgz#67658a1d944edb53c8d4fa3004473a0dd7838150" + integrity sha512-6ZKNgMQmQmrEX/ncuCwnnw1yVGoaOW5KpxNhoWI7pCQdA0uZ0HqHGqenCUIENAnxRjy2WwNQ30gfGdIgqJXXqw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-object-super@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.2.0.tgz#b35d4c10f56bab5d650047dad0f1d8e8814b6598" + integrity sha512-VMyhPYZISFZAqAPVkiYb7dUe2AsVi2/wCT5+wZdsNO31FojQJa9ns40hzZ6U9f50Jlq4w6qwzdBB2uwqZ00ebg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.1.0" + +"@babel/plugin-transform-parameters@^7.2.0", "@babel/plugin-transform-parameters@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.4.3.tgz#e5ff62929fdf4cf93e58badb5e2430303003800d" + integrity sha512-ULJYC2Vnw96/zdotCZkMGr2QVfKpIT/4/K+xWWY0MbOJyMZuk660BGkr3bEKWQrrciwz6xpmft39nA4BF7hJuA== + dependencies: + "@babel/helper-call-delegate" "^7.4.0" + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-property-literals@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.2.0.tgz#03e33f653f5b25c4eb572c98b9485055b389e905" + integrity sha512-9q7Dbk4RhgcLp8ebduOpCbtjh7C0itoLYHXd9ueASKAG/is5PQtMR5VJGka9NKqGhYEGn5ITahd4h9QeBMylWQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-react-constant-elements@7.2.0", "@babel/plugin-transform-react-constant-elements@^7.0.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.2.0.tgz#ed602dc2d8bff2f0cb1a5ce29263dbdec40779f7" + integrity sha512-YYQFg6giRFMsZPKUM9v+VcHOdfSQdz9jHCx3akAi3UYgyjndmdYGSXylQ/V+HswQt4fL8IklchD9HTsaOCrWQQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-react-display-name@7.2.0", "@babel/plugin-transform-react-display-name@^7.0.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.2.0.tgz#ebfaed87834ce8dc4279609a4f0c324c156e3eb0" + integrity sha512-Htf/tPa5haZvRMiNSQSFifK12gtr/8vwfr+A9y69uF0QcU77AVu4K7MiHEkTxF7lQoHOL0F9ErqgfNEAKgXj7A== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-react-jsx-self@^7.0.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.2.0.tgz#461e21ad9478f1031dd5e276108d027f1b5240ba" + integrity sha512-v6S5L/myicZEy+jr6ielB0OR8h+EH/1QFx/YJ7c7Ua+7lqsjj/vW6fD5FR9hB/6y7mGbfT4vAURn3xqBxsUcdg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@babel/plugin-transform-react-jsx-source@^7.0.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.2.0.tgz#20c8c60f0140f5dd3cd63418d452801cf3f7180f" + integrity sha512-A32OkKTp4i5U6aE88GwwcuV4HAprUgHcTq0sSafLxjr6AW0QahrCRCjxogkbbcdtpbXkuTOlgpjophCxb6sh5g== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@babel/plugin-transform-react-jsx@^7.0.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz#f2cab99026631c767e2745a5368b331cfe8f5290" + integrity sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg== + dependencies: + "@babel/helper-builder-react-jsx" "^7.3.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@babel/plugin-transform-regenerator@^7.0.0", "@babel/plugin-transform-regenerator@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.3.tgz#2a697af96887e2bbf5d303ab0221d139de5e739c" + integrity sha512-kEzotPuOpv6/iSlHroCDydPkKYw7tiJGKlmYp6iJn4a6C/+b2FdttlJsLKYxolYHgotTJ5G5UY5h0qey5ka3+A== + dependencies: + regenerator-transform "^0.13.4" + +"@babel/plugin-transform-reserved-words@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.2.0.tgz#4792af87c998a49367597d07fedf02636d2e1634" + integrity sha512-fz43fqW8E1tAB3DKF19/vxbpib1fuyCwSPE418ge5ZxILnBhWyhtPgz8eh1RCGGJlwvksHkyxMxh0eenFi+kFw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-runtime@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.2.0.tgz#566bc43f7d0aedc880eaddbd29168d0f248966ea" + integrity sha512-jIgkljDdq4RYDnJyQsiWbdvGeei/0MOTtSHKO/rfbd/mXBxNpdlulMx49L0HQ4pug1fXannxoqCI+fYSle9eSw== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + resolve "^1.8.1" + semver "^5.5.1" + +"@babel/plugin-transform-shorthand-properties@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz#6333aee2f8d6ee7e28615457298934a3b46198f0" + integrity sha512-QP4eUM83ha9zmYtpbnyjTLAGKQritA5XW/iG9cjtuOI8s1RuL/3V6a3DeSHfKutJQ+ayUfeZJPcnCYEQzaPQqg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-spread@^7.2.0": + version "7.2.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.2.2.tgz#3103a9abe22f742b6d406ecd3cd49b774919b406" + integrity sha512-KWfky/58vubwtS0hLqEnrWJjsMGaOeSBn90Ezn5Jeg9Z8KKHmELbP1yGylMlm5N6TPKeY9A2+UaSYLdxahg01w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-sticky-regex@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.2.0.tgz#a1e454b5995560a9c1e0d537dfc15061fd2687e1" + integrity sha512-KKYCoGaRAf+ckH8gEL3JHUaFVyNHKe3ASNsZ+AlktgHevvxGigoIttrEJb8iKN03Q7Eazlv1s6cx2B2cQ3Jabw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.0.0" + +"@babel/plugin-transform-template-literals@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.2.0.tgz#d87ed01b8eaac7a92473f608c97c089de2ba1e5b" + integrity sha512-FkPix00J9A/XWXv4VoKJBMeSkyY9x/TqIh76wzcdfl57RJJcf8CehQ08uwfhCDNtRQYtHQKBTwKZDEyjE13Lwg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-typeof-symbol@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.2.0.tgz#117d2bcec2fbf64b4b59d1f9819894682d29f2b2" + integrity sha512-2LNhETWYxiYysBtrBTqL8+La0jIoQQnIScUJc74OYvUGRmkskNY4EzLCnjHBzdmb38wqtTaixpo1NctEcvMDZw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-typescript@^7.1.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.4.0.tgz#0389ec53a34e80f99f708c4ca311181449a68eb1" + integrity sha512-U7/+zKnRZg04ggM/Bm+xmu2B/PrwyDQTT/V89FXWYWNMxBDwSx56u6jtk9SEbfLFbZaEI72L+5LPvQjeZgFCrQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-typescript" "^7.2.0" + +"@babel/plugin-transform-unicode-regex@^7.2.0", "@babel/plugin-transform-unicode-regex@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.3.tgz#3868703fc0e8f443dda65654b298df576f7b863b" + integrity sha512-lnSNgkVjL8EMtnE8eSS7t2ku8qvKH3eqNf/IwIfnSPUqzgqYmRwzdsQWv4mNQAN9Nuo6Gz1Y0a4CSmdpu1Pp6g== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.4.3" + regexpu-core "^4.5.4" + +"@babel/preset-env@7.3.1": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.3.1.tgz#389e8ca6b17ae67aaf9a2111665030be923515db" + integrity sha512-FHKrD6Dxf30e8xgHQO0zJZpUPfVZg+Xwgz5/RdSWCbza9QLNk4Qbp40ctRoqDxml3O8RMzB1DU55SXeDG6PqHQ== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-proposal-async-generator-functions" "^7.2.0" + "@babel/plugin-proposal-json-strings" "^7.2.0" + "@babel/plugin-proposal-object-rest-spread" "^7.3.1" + "@babel/plugin-proposal-optional-catch-binding" "^7.2.0" + "@babel/plugin-proposal-unicode-property-regex" "^7.2.0" + "@babel/plugin-syntax-async-generators" "^7.2.0" + "@babel/plugin-syntax-json-strings" "^7.2.0" + "@babel/plugin-syntax-object-rest-spread" "^7.2.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" + "@babel/plugin-transform-arrow-functions" "^7.2.0" + "@babel/plugin-transform-async-to-generator" "^7.2.0" + "@babel/plugin-transform-block-scoped-functions" "^7.2.0" + "@babel/plugin-transform-block-scoping" "^7.2.0" + "@babel/plugin-transform-classes" "^7.2.0" + "@babel/plugin-transform-computed-properties" "^7.2.0" + "@babel/plugin-transform-destructuring" "^7.2.0" + "@babel/plugin-transform-dotall-regex" "^7.2.0" + "@babel/plugin-transform-duplicate-keys" "^7.2.0" + "@babel/plugin-transform-exponentiation-operator" "^7.2.0" + "@babel/plugin-transform-for-of" "^7.2.0" + "@babel/plugin-transform-function-name" "^7.2.0" + "@babel/plugin-transform-literals" "^7.2.0" + "@babel/plugin-transform-modules-amd" "^7.2.0" + "@babel/plugin-transform-modules-commonjs" "^7.2.0" + "@babel/plugin-transform-modules-systemjs" "^7.2.0" + "@babel/plugin-transform-modules-umd" "^7.2.0" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.3.0" + "@babel/plugin-transform-new-target" "^7.0.0" + "@babel/plugin-transform-object-super" "^7.2.0" + "@babel/plugin-transform-parameters" "^7.2.0" + "@babel/plugin-transform-regenerator" "^7.0.0" + "@babel/plugin-transform-shorthand-properties" "^7.2.0" + "@babel/plugin-transform-spread" "^7.2.0" + "@babel/plugin-transform-sticky-regex" "^7.2.0" + "@babel/plugin-transform-template-literals" "^7.2.0" + "@babel/plugin-transform-typeof-symbol" "^7.2.0" + "@babel/plugin-transform-unicode-regex" "^7.2.0" + browserslist "^4.3.4" + invariant "^2.2.2" + js-levenshtein "^1.1.3" + semver "^5.3.0" + +"@babel/preset-env@^7.1.6": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.4.3.tgz#e71e16e123dc0fbf65a52cbcbcefd072fbd02880" + integrity sha512-FYbZdV12yHdJU5Z70cEg0f6lvtpZ8jFSDakTm7WXeJbLXh4R0ztGEu/SW7G1nJ2ZvKwDhz8YrbA84eYyprmGqw== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-proposal-async-generator-functions" "^7.2.0" + "@babel/plugin-proposal-json-strings" "^7.2.0" + "@babel/plugin-proposal-object-rest-spread" "^7.4.3" + "@babel/plugin-proposal-optional-catch-binding" "^7.2.0" + "@babel/plugin-proposal-unicode-property-regex" "^7.4.0" + "@babel/plugin-syntax-async-generators" "^7.2.0" + "@babel/plugin-syntax-json-strings" "^7.2.0" + "@babel/plugin-syntax-object-rest-spread" "^7.2.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" + "@babel/plugin-transform-arrow-functions" "^7.2.0" + "@babel/plugin-transform-async-to-generator" "^7.4.0" + "@babel/plugin-transform-block-scoped-functions" "^7.2.0" + "@babel/plugin-transform-block-scoping" "^7.4.0" + "@babel/plugin-transform-classes" "^7.4.3" + "@babel/plugin-transform-computed-properties" "^7.2.0" + "@babel/plugin-transform-destructuring" "^7.4.3" + "@babel/plugin-transform-dotall-regex" "^7.4.3" + "@babel/plugin-transform-duplicate-keys" "^7.2.0" + "@babel/plugin-transform-exponentiation-operator" "^7.2.0" + "@babel/plugin-transform-for-of" "^7.4.3" + "@babel/plugin-transform-function-name" "^7.4.3" + "@babel/plugin-transform-literals" "^7.2.0" + "@babel/plugin-transform-member-expression-literals" "^7.2.0" + "@babel/plugin-transform-modules-amd" "^7.2.0" + "@babel/plugin-transform-modules-commonjs" "^7.4.3" + "@babel/plugin-transform-modules-systemjs" "^7.4.0" + "@babel/plugin-transform-modules-umd" "^7.2.0" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.4.2" + "@babel/plugin-transform-new-target" "^7.4.0" + "@babel/plugin-transform-object-super" "^7.2.0" + "@babel/plugin-transform-parameters" "^7.4.3" + "@babel/plugin-transform-property-literals" "^7.2.0" + "@babel/plugin-transform-regenerator" "^7.4.3" + "@babel/plugin-transform-reserved-words" "^7.2.0" + "@babel/plugin-transform-shorthand-properties" "^7.2.0" + "@babel/plugin-transform-spread" "^7.2.0" + "@babel/plugin-transform-sticky-regex" "^7.2.0" + "@babel/plugin-transform-template-literals" "^7.2.0" + "@babel/plugin-transform-typeof-symbol" "^7.2.0" + "@babel/plugin-transform-unicode-regex" "^7.4.3" + "@babel/types" "^7.4.0" + browserslist "^4.5.2" + core-js-compat "^3.0.0" + invariant "^2.2.2" + js-levenshtein "^1.1.3" + semver "^5.5.0" + +"@babel/preset-react@7.0.0", "@babel/preset-react@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.0.0.tgz#e86b4b3d99433c7b3e9e91747e2653958bc6b3c0" + integrity sha512-oayxyPS4Zj+hF6Et11BwuBkmpgT/zMxyuZgFrMeZID6Hdh3dGlk4sHCAhdBCpuCKW2ppBfl2uCCetlrUIJRY3w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-transform-react-display-name" "^7.0.0" + "@babel/plugin-transform-react-jsx" "^7.0.0" + "@babel/plugin-transform-react-jsx-self" "^7.0.0" + "@babel/plugin-transform-react-jsx-source" "^7.0.0" + +"@babel/preset-typescript@7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.1.0.tgz#49ad6e2084ff0bfb5f1f7fb3b5e76c434d442c7f" + integrity sha512-LYveByuF9AOM8WrsNne5+N79k1YxjNB6gmpCQsnuSBAcV8QUeB+ZUxQzL7Rz7HksPbahymKkq2qBR+o36ggFZA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-transform-typescript" "^7.1.0" + +"@babel/runtime@7.3.1": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.1.tgz#574b03e8e8a9898eaf4a872a92ea20b7846f6f2a" + integrity sha512-7jGW8ppV0ant637pIqAcFfQDDH1orEPGJb8aXfUozuCU3QqX7rX4DA8iwrbPrR1hcH0FTTHz47yQnk+bl5xHQA== + dependencies: + regenerator-runtime "^0.12.0" + +"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.3.tgz#79888e452034223ad9609187a0ad1fe0d2ad4bdc" + integrity sha512-9lsJwJLxDh/T3Q3SZszfWOTkk3pHbkmH+3KY+zwIDmsNlxsumuhS2TH3NIpktU4kNvfzy+k3eLT7aTJSPTo0OA== + dependencies: + regenerator-runtime "^0.13.2" + +"@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.2.2", "@babel/template@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.0.tgz#12474e9c077bae585c5d835a95c0b0b790c25c8b" + integrity sha512-SOWwxxClTTh5NdbbYZ0BmaBVzxzTh2tO/TeLTbF6MO6EzVhHTnff8CdBXx3mEtazFBoysmEM6GU/wF+SuSx4Fw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.4.0" + "@babel/types" "^7.4.0" + +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.2.2", "@babel/traverse@^7.4.0", "@babel/traverse@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.4.3.tgz#1a01f078fc575d589ff30c0f71bf3c3d9ccbad84" + integrity sha512-HmA01qrtaCwwJWpSKpA948cBvU5BrmviAief/b3AVw936DtcdsTexlbyzNuDnthwhOQ37xshn7hvQaEQk7ISYQ== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.4.0" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.4.0" + "@babel/parser" "^7.4.3" + "@babel/types" "^7.4.0" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.11" + +"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.3.0", "@babel/types@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.4.0.tgz#670724f77d24cce6cc7d8cf64599d511d164894c" + integrity sha512-aPvkXyU2SPOnztlgo8n9cEiXW755mgyvueUPcpStqdzoSPm0fjO0vQBjLkt3JKJW7ufikfcnMTTPsN1xaTsBPA== + dependencies: + esutils "^2.0.2" + lodash "^4.17.11" + to-fast-properties "^2.0.0" + +"@cnakazawa/watch@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef" + integrity sha512-r5160ogAvGyHsal38Kux7YYtodEKOj89RGb28ht1jh3SJb08VwRwAKKJL0bGb04Zd/3r9FL3BFIc3bBidYffCA== + dependencies: + exec-sh "^0.3.2" + minimist "^1.2.0" + +"@csstools/convert-colors@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" + integrity sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw== + +"@jest/console@^24.7.1": + version "24.7.1" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.7.1.tgz#32a9e42535a97aedfe037e725bd67e954b459545" + integrity sha512-iNhtIy2M8bXlAOULWVTUxmnelTLFneTNEkHCgPmgd+zNwy9zVddJ6oS5rZ9iwoscNdT5mMwUd0C51v/fSlzItg== + dependencies: + "@jest/source-map" "^24.3.0" + chalk "^2.0.1" + slash "^2.0.0" + +"@jest/environment@^24.7.1": + version "24.7.1" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-24.7.1.tgz#9b9196bc737561f67ac07817d4c5ece772e33135" + integrity sha512-wmcTTYc4/KqA+U5h1zQd5FXXynfa7VGP2NfF+c6QeGJ7c+2nStgh65RQWNX62SC716dTtqheTRrZl0j+54oGHw== + dependencies: + "@jest/fake-timers" "^24.7.1" + "@jest/transform" "^24.7.1" + "@jest/types" "^24.7.0" + jest-mock "^24.7.0" + +"@jest/fake-timers@^24.7.1": + version "24.7.1" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-24.7.1.tgz#56e5d09bdec09ee81050eaff2794b26c71d19db2" + integrity sha512-4vSQJDKfR2jScOe12L9282uiwuwQv9Lk7mgrCSZHA9evB9efB/qx8i0KJxsAKtp8fgJYBJdYY7ZU6u3F4/pyjA== + dependencies: + "@jest/types" "^24.7.0" + jest-message-util "^24.7.1" + jest-mock "^24.7.0" + +"@jest/source-map@^24.3.0": + version "24.3.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-24.3.0.tgz#563be3aa4d224caf65ff77edc95cd1ca4da67f28" + integrity sha512-zALZt1t2ou8le/crCeeiRYzvdnTzaIlpOWaet45lNSqNJUnXbppUUFR4ZUAlzgDmKee4Q5P/tKXypI1RiHwgag== + dependencies: + callsites "^3.0.0" + graceful-fs "^4.1.15" + source-map "^0.6.0" + +"@jest/test-result@^24.7.1": + version "24.7.1" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-24.7.1.tgz#19eacdb29a114300aed24db651e5d975f08b6bbe" + integrity sha512-3U7wITxstdEc2HMfBX7Yx3JZgiNBubwDqQMh+BXmZXHa3G13YWF3p6cK+5g0hGkN3iufg/vGPl3hLxQXD74Npg== + dependencies: + "@jest/console" "^24.7.1" + "@jest/types" "^24.7.0" + "@types/istanbul-lib-coverage" "^2.0.0" + +"@jest/transform@^24.7.1": + version "24.7.1" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-24.7.1.tgz#872318f125bcfab2de11f53b465ab1aa780789c2" + integrity sha512-EsOUqP9ULuJ66IkZQhI5LufCHlTbi7hrcllRMUEV/tOgqBVQi93+9qEvkX0n8mYpVXQ8VjwmICeRgg58mrtIEw== + dependencies: + "@babel/core" "^7.1.0" + "@jest/types" "^24.7.0" + babel-plugin-istanbul "^5.1.0" + chalk "^2.0.1" + convert-source-map "^1.4.0" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.1.15" + jest-haste-map "^24.7.1" + jest-regex-util "^24.3.0" + jest-util "^24.7.1" + micromatch "^3.1.10" + realpath-native "^1.1.0" + slash "^2.0.0" + source-map "^0.6.1" + write-file-atomic "2.4.1" + +"@jest/types@^24.7.0": + version "24.7.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.7.0.tgz#c4ec8d1828cdf23234d9b4ee31f5482a3f04f48b" + integrity sha512-ipJUa2rFWiKoBqMKP63Myb6h9+iT3FHRTF2M8OR6irxWzItisa8i4dcSg14IbvmXUnBlHBlUQPYUHWyX3UPpYA== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/yargs" "^12.0.9" + +"@mrmlnc/readdir-enhanced@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" + integrity sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g== + dependencies: + call-me-maybe "^1.0.1" + glob-to-regexp "^0.3.0" + +"@nodelib/fs.stat@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" + integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== + +"@svgr/babel-plugin-add-jsx-attribute@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz#dadcb6218503532d6884b210e7f3c502caaa44b1" + integrity sha512-j7KnilGyZzYr/jhcrSYS3FGWMZVaqyCG0vzMCwzvei0coIkczuYMcniK07nI0aHJINciujjH11T72ICW5eL5Ig== + +"@svgr/babel-plugin-remove-jsx-attribute@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-4.2.0.tgz#297550b9a8c0c7337bea12bdfc8a80bb66f85abc" + integrity sha512-3XHLtJ+HbRCH4n28S7y/yZoEQnRpl0tvTZQsHqvaeNXPra+6vE5tbRliH3ox1yZYPCxrlqaJT/Mg+75GpDKlvQ== + +"@svgr/babel-plugin-remove-jsx-empty-expression@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-4.2.0.tgz#c196302f3e68eab6a05e98af9ca8570bc13131c7" + integrity sha512-yTr2iLdf6oEuUE9MsRdvt0NmdpMBAkgK8Bjhl6epb+eQWk6abBaX3d65UZ3E3FWaOwePyUgNyNCMVG61gGCQ7w== + +"@svgr/babel-plugin-replace-jsx-attribute-value@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-4.2.0.tgz#310ec0775de808a6a2e4fd4268c245fd734c1165" + integrity sha512-U9m870Kqm0ko8beHawRXLGLvSi/ZMrl89gJ5BNcT452fAjtF2p4uRzXkdzvGJJJYBgx7BmqlDjBN/eCp5AAX2w== + +"@svgr/babel-plugin-svg-dynamic-title@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-4.2.0.tgz#43f0f689a5347a894160eb51b39a109889a4df20" + integrity sha512-gH2qItapwCUp6CCqbxvzBbc4dh4OyxdYKsW3EOkYexr0XUmQL0ScbdNh6DexkZ01T+sdClniIbnCObsXcnx3sQ== + +"@svgr/babel-plugin-svg-em-dimensions@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-4.2.0.tgz#9a94791c9a288108d20a9d2cc64cac820f141391" + integrity sha512-C0Uy+BHolCHGOZ8Dnr1zXy/KgpBOkEUYY9kI/HseHVPeMbluaX3CijJr7D4C5uR8zrc1T64nnq/k63ydQuGt4w== + +"@svgr/babel-plugin-transform-react-native-svg@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-4.2.0.tgz#151487322843359a1ca86b21a3815fd21a88b717" + integrity sha512-7YvynOpZDpCOUoIVlaaOUU87J4Z6RdD6spYN4eUb5tfPoKGSF9OG2NuhgYnq4jSkAxcpMaXWPf1cePkzmqTPNw== + +"@svgr/babel-plugin-transform-svg-component@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-4.2.0.tgz#5f1e2f886b2c85c67e76da42f0f6be1b1767b697" + integrity sha512-hYfYuZhQPCBVotABsXKSCfel2slf/yvJY8heTVX1PCTaq/IgASq1IyxPPKJ0chWREEKewIU/JMSsIGBtK1KKxw== + +"@svgr/babel-preset@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-preset/-/babel-preset-4.2.0.tgz#c9fc236445a02a8cd4e750085e51c181de00d6c5" + integrity sha512-iLetHpRCQXfK47voAs5/uxd736cCyocEdorisjAveZo8ShxJ/ivSZgstBmucI1c8HyMF5tOrilJLoFbhpkPiKw== + dependencies: + "@svgr/babel-plugin-add-jsx-attribute" "^4.2.0" + "@svgr/babel-plugin-remove-jsx-attribute" "^4.2.0" + "@svgr/babel-plugin-remove-jsx-empty-expression" "^4.2.0" + "@svgr/babel-plugin-replace-jsx-attribute-value" "^4.2.0" + "@svgr/babel-plugin-svg-dynamic-title" "^4.2.0" + "@svgr/babel-plugin-svg-em-dimensions" "^4.2.0" + "@svgr/babel-plugin-transform-react-native-svg" "^4.2.0" + "@svgr/babel-plugin-transform-svg-component" "^4.2.0" + +"@svgr/core@^4.1.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/core/-/core-4.2.0.tgz#f32ef8b9d05312aaa775896ec30ae46a6521e248" + integrity sha512-nvzXaf2VavqjMCTTfsZfjL4o9035KedALkMzk82qOlHOwBb8JT+9+zYDgBl0oOunbVF94WTLnvGunEg0csNP3Q== + dependencies: + "@svgr/plugin-jsx" "^4.2.0" + camelcase "^5.3.1" + cosmiconfig "^5.2.0" + +"@svgr/hast-util-to-babel-ast@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-4.2.0.tgz#dd743435a5f3a8e84a1da067f27b5fae3d7b6b63" + integrity sha512-IvAeb7gqrGB5TH9EGyBsPrMRH/QCzIuAkLySKvH2TLfLb2uqk98qtJamordRQTpHH3e6TORfBXoTo7L7Opo/Ow== + dependencies: + "@babel/types" "^7.4.0" + +"@svgr/plugin-jsx@^4.1.0", "@svgr/plugin-jsx@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/plugin-jsx/-/plugin-jsx-4.2.0.tgz#15a91562c9b5f90640ea0bdcb2ad59d692ee7ae9" + integrity sha512-AM1YokmZITgveY9bulLVquqNmwiFo2Px2HL+IlnTCR01YvWDfRL5QKdnF7VjRaS5MNP938mmqvL0/8oz3zQMkg== + dependencies: + "@babel/core" "^7.4.3" + "@svgr/babel-preset" "^4.2.0" + "@svgr/hast-util-to-babel-ast" "^4.2.0" + rehype-parse "^6.0.0" + unified "^7.1.0" + vfile "^4.0.0" + +"@svgr/plugin-svgo@^4.0.3": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@svgr/plugin-svgo/-/plugin-svgo-4.2.0.tgz#2a594a2d3312955e75fd87dc77ae51f377c809f3" + integrity sha512-zUEKgkT172YzHh3mb2B2q92xCnOAMVjRx+o0waZ1U50XqKLrVQ/8dDqTAtnmapdLsGurv8PSwenjLCUpj6hcvw== + dependencies: + cosmiconfig "^5.2.0" + merge-deep "^3.0.2" + svgo "^1.2.1" + +"@svgr/webpack@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@svgr/webpack/-/webpack-4.1.0.tgz#20c88f32f731c7b1d4711045b2b993887d731c28" + integrity sha512-d09ehQWqLMywP/PT/5JvXwPskPK9QCXUjiSkAHehreB381qExXf5JFCBWhfEyNonRbkIneCeYM99w+Ud48YIQQ== + dependencies: + "@babel/core" "^7.1.6" + "@babel/plugin-transform-react-constant-elements" "^7.0.0" + "@babel/preset-env" "^7.1.6" + "@babel/preset-react" "^7.0.0" + "@svgr/core" "^4.1.0" + "@svgr/plugin-jsx" "^4.1.0" + "@svgr/plugin-svgo" "^4.0.3" + loader-utils "^1.1.0" + +"@types/istanbul-lib-coverage@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.0.tgz#1eb8c033e98cf4e1a4cedcaf8bcafe8cb7591e85" + integrity sha512-eAtOAFZefEnfJiRFQBGw1eYqa5GTLCZ1y86N0XSI/D6EB+E8z6VPV/UL7Gi5UEclFqoQk+6NRqEDsfmDLXn8sg== + +"@types/node@*": + version "11.13.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.4.tgz#f83ec3c3e05b174b7241fadeb6688267fe5b22ca" + integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ== + +"@types/q@^1.5.1": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" + integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== + +"@types/stack-utils@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" + integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== + +"@types/tapable@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.2.tgz#e13182e1b69871a422d7863e11a4a6f5b814a4bd" + integrity sha512-42zEJkBpNfMEAvWR5WlwtTH22oDzcMjFsL9gDGExwF8X8WvAiw7Vwop7hPw03QT8TKfec83LwbHj6SvpqM4ELQ== + +"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" + integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== + +"@types/vfile-message@*": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/vfile-message/-/vfile-message-1.0.1.tgz#e1e9895cc6b36c462d4244e64e6d0b6eaf65355a" + integrity sha512-mlGER3Aqmq7bqR1tTTIVHq8KSAFFRyGbrxuM8C/H82g6k7r2fS+IMEkIu3D7JHzG10NvPdR8DNx0jr0pwpp4dA== + dependencies: + "@types/node" "*" + "@types/unist" "*" + +"@types/vfile@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/vfile/-/vfile-3.0.2.tgz#19c18cd232df11ce6fa6ad80259bc86c366b09b9" + integrity sha512-b3nLFGaGkJ9rzOcuXRfHkZMdjsawuDD0ENL9fzTophtBg8FJHSGbH7daXkEpcwy3v7Xol3pAvsmlYyFhR4pqJw== + dependencies: + "@types/node" "*" + "@types/unist" "*" + "@types/vfile-message" "*" + +"@types/yargs@^12.0.9": + version "12.0.12" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.12.tgz#45dd1d0638e8c8f153e87d296907659296873916" + integrity sha512-SOhuU4wNBxhhTHxYaiG5NY4HBhDIDnJF60GU+2LqHAdKKer86//e4yg69aENCtQ04n0ovz+tq2YPME5t5yp4pw== + +"@webassemblyjs/ast@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.7.11.tgz#b988582cafbb2b095e8b556526f30c90d057cace" + integrity sha512-ZEzy4vjvTzScC+SH8RBssQUawpaInUdMTYwYYLh54/s8TuT0gBLuyUnppKsVyZEi876VmmStKsUs28UxPgdvrA== + dependencies: + "@webassemblyjs/helper-module-context" "1.7.11" + "@webassemblyjs/helper-wasm-bytecode" "1.7.11" + "@webassemblyjs/wast-parser" "1.7.11" + +"@webassemblyjs/floating-point-hex-parser@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.7.11.tgz#a69f0af6502eb9a3c045555b1a6129d3d3f2e313" + integrity sha512-zY8dSNyYcgzNRNT666/zOoAyImshm3ycKdoLsyDw/Bwo6+/uktb7p4xyApuef1dwEBo/U/SYQzbGBvV+nru2Xg== + +"@webassemblyjs/helper-api-error@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.7.11.tgz#c7b6bb8105f84039511a2b39ce494f193818a32a" + integrity sha512-7r1qXLmiglC+wPNkGuXCvkmalyEstKVwcueZRP2GNC2PAvxbLYwLLPr14rcdJaE4UtHxQKfFkuDFuv91ipqvXg== + +"@webassemblyjs/helper-buffer@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.7.11.tgz#3122d48dcc6c9456ed982debe16c8f37101df39b" + integrity sha512-MynuervdylPPh3ix+mKZloTcL06P8tenNH3sx6s0qE8SLR6DdwnfgA7Hc9NSYeob2jrW5Vql6GVlsQzKQCa13w== + +"@webassemblyjs/helper-code-frame@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.7.11.tgz#cf8f106e746662a0da29bdef635fcd3d1248364b" + integrity sha512-T8ESC9KMXFTXA5urJcyor5cn6qWeZ4/zLPyWeEXZ03hj/x9weSokGNkVCdnhSabKGYWxElSdgJ+sFa9G/RdHNw== + dependencies: + "@webassemblyjs/wast-printer" "1.7.11" + +"@webassemblyjs/helper-fsm@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.7.11.tgz#df38882a624080d03f7503f93e3f17ac5ac01181" + integrity sha512-nsAQWNP1+8Z6tkzdYlXT0kxfa2Z1tRTARd8wYnc/e3Zv3VydVVnaeePgqUzFrpkGUyhUUxOl5ML7f1NuT+gC0A== + +"@webassemblyjs/helper-module-context@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.7.11.tgz#d874d722e51e62ac202476935d649c802fa0e209" + integrity sha512-JxfD5DX8Ygq4PvXDucq0M+sbUFA7BJAv/GGl9ITovqE+idGX+J3QSzJYz+LwQmL7fC3Rs+utvWoJxDb6pmC0qg== + +"@webassemblyjs/helper-wasm-bytecode@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.7.11.tgz#dd9a1e817f1c2eb105b4cf1013093cb9f3c9cb06" + integrity sha512-cMXeVS9rhoXsI9LLL4tJxBgVD/KMOKXuFqYb5oCJ/opScWpkCMEz9EJtkonaNcnLv2R3K5jIeS4TRj/drde1JQ== + +"@webassemblyjs/helper-wasm-section@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.7.11.tgz#9c9ac41ecf9fbcfffc96f6d2675e2de33811e68a" + integrity sha512-8ZRY5iZbZdtNFE5UFunB8mmBEAbSI3guwbrsCl4fWdfRiAcvqQpeqd5KHhSWLL5wuxo53zcaGZDBU64qgn4I4Q== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/helper-buffer" "1.7.11" + "@webassemblyjs/helper-wasm-bytecode" "1.7.11" + "@webassemblyjs/wasm-gen" "1.7.11" + +"@webassemblyjs/ieee754@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.7.11.tgz#c95839eb63757a31880aaec7b6512d4191ac640b" + integrity sha512-Mmqx/cS68K1tSrvRLtaV/Lp3NZWzXtOHUW2IvDvl2sihAwJh4ACE0eL6A8FvMyDG9abes3saB6dMimLOs+HMoQ== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.7.11.tgz#d7267a1ee9c4594fd3f7e37298818ec65687db63" + integrity sha512-vuGmgZjjp3zjcerQg+JA+tGOncOnJLWVkt8Aze5eWQLwTQGNgVLcyOTqgSCxWTR4J42ijHbBxnuRaL1Rv7XMdw== + dependencies: + "@xtuc/long" "4.2.1" + +"@webassemblyjs/utf8@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.7.11.tgz#06d7218ea9fdc94a6793aa92208160db3d26ee82" + integrity sha512-C6GFkc7aErQIAH+BMrIdVSmW+6HSe20wg57HEC1uqJP8E/xpMjXqQUxkQw07MhNDSDcGpxI9G5JSNOQCqJk4sA== + +"@webassemblyjs/wasm-edit@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.7.11.tgz#8c74ca474d4f951d01dbae9bd70814ee22a82005" + integrity sha512-FUd97guNGsCZQgeTPKdgxJhBXkUbMTY6hFPf2Y4OedXd48H97J+sOY2Ltaq6WGVpIH8o/TGOVNiVz/SbpEMJGg== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/helper-buffer" "1.7.11" + "@webassemblyjs/helper-wasm-bytecode" "1.7.11" + "@webassemblyjs/helper-wasm-section" "1.7.11" + "@webassemblyjs/wasm-gen" "1.7.11" + "@webassemblyjs/wasm-opt" "1.7.11" + "@webassemblyjs/wasm-parser" "1.7.11" + "@webassemblyjs/wast-printer" "1.7.11" + +"@webassemblyjs/wasm-gen@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.7.11.tgz#9bbba942f22375686a6fb759afcd7ac9c45da1a8" + integrity sha512-U/KDYp7fgAZX5KPfq4NOupK/BmhDc5Kjy2GIqstMhvvdJRcER/kUsMThpWeRP8BMn4LXaKhSTggIJPOeYHwISA== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/helper-wasm-bytecode" "1.7.11" + "@webassemblyjs/ieee754" "1.7.11" + "@webassemblyjs/leb128" "1.7.11" + "@webassemblyjs/utf8" "1.7.11" + +"@webassemblyjs/wasm-opt@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.7.11.tgz#b331e8e7cef8f8e2f007d42c3a36a0580a7d6ca7" + integrity sha512-XynkOwQyiRidh0GLua7SkeHvAPXQV/RxsUeERILmAInZegApOUAIJfRuPYe2F7RcjOC9tW3Cb9juPvAC/sCqvg== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/helper-buffer" "1.7.11" + "@webassemblyjs/wasm-gen" "1.7.11" + "@webassemblyjs/wasm-parser" "1.7.11" + +"@webassemblyjs/wasm-parser@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.7.11.tgz#6e3d20fa6a3519f6b084ef9391ad58211efb0a1a" + integrity sha512-6lmXRTrrZjYD8Ng8xRyvyXQJYUQKYSXhJqXOBLw24rdiXsHAOlvw5PhesjdcaMadU/pyPQOJ5dHreMjBxwnQKg== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/helper-api-error" "1.7.11" + "@webassemblyjs/helper-wasm-bytecode" "1.7.11" + "@webassemblyjs/ieee754" "1.7.11" + "@webassemblyjs/leb128" "1.7.11" + "@webassemblyjs/utf8" "1.7.11" + +"@webassemblyjs/wast-parser@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.7.11.tgz#25bd117562ca8c002720ff8116ef9072d9ca869c" + integrity sha512-lEyVCg2np15tS+dm7+JJTNhNWq9yTZvi3qEhAIIOaofcYlUp0UR5/tVqOwa/gXYr3gjwSZqw+/lS9dscyLelbQ== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/floating-point-hex-parser" "1.7.11" + "@webassemblyjs/helper-api-error" "1.7.11" + "@webassemblyjs/helper-code-frame" "1.7.11" + "@webassemblyjs/helper-fsm" "1.7.11" + "@xtuc/long" "4.2.1" + +"@webassemblyjs/wast-printer@1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.7.11.tgz#c4245b6de242cb50a2cc950174fdbf65c78d7813" + integrity sha512-m5vkAsuJ32QpkdkDOUPGSltrg8Cuk3KBx4YrmAGQwCZPRdUHXxG4phIOuuycLemHFr74sWL9Wthqss4fzdzSwg== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/wast-parser" "1.7.11" + "@xtuc/long" "4.2.1" + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.1.tgz#5c85d662f76fa1d34575766c5dcd6615abcd30d8" + integrity sha512-FZdkNBDqBRHKQ2MEbSC17xnPFOhZxeJ2YGSfr2BKf3sujG49Qe3bB+rGCwQfIaA7WHnGeGkSijX4FuBCdrzW/g== + +abab@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f" + integrity sha512-sY5AXXVZv4Y1VACTtR11UJCPHHudgY5i26Qj5TypE6DKlIApbwb5uqhXcJ5UUGbvZNRh7EeIoW+LrJumBsKp7w== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +accepts@~1.3.4, accepts@~1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2" + integrity sha1-63d99gEXI6OxTopywIBcjoZ0a9I= + dependencies: + mime-types "~2.1.18" + negotiator "0.6.1" + +acorn-dynamic-import@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-3.0.0.tgz#901ceee4c7faaef7e07ad2a47e890675da50a278" + integrity sha512-zVWV8Z8lislJoOKKqdNMOB+s6+XV5WERty8MnKBeFgwA+19XJjJHs2RP5dzM57FftIs+jQnRToLiWazKr6sSWg== + dependencies: + acorn "^5.0.0" + +acorn-globals@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.0.tgz#e3b6f8da3c1552a95ae627571f7dd6923bb54103" + integrity sha512-hMtHj3s5RnuhvHPowpBYvJVj3rAar82JiDQHvGs1zO0l10ocX/xEdBShNHTJaboucJUsScghp74pH3s7EnHHQw== + dependencies: + acorn "^6.0.1" + acorn-walk "^6.0.1" + +acorn-jsx@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.1.tgz#32a064fd925429216a09b141102bfdd185fae40e" + integrity sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg== + +acorn-walk@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.1.1.tgz#d363b66f5fac5f018ff9c3a1e7b6f8e310cc3913" + integrity sha512-OtUw6JUTgxA2QoqqmrmQ7F2NYqiBPi/L2jqHyFtllhOUvXYQXf0Z1CYUinIfyT4bTCGmrA7gX9FvHA81uzCoVw== + +acorn@^5.0.0, acorn@^5.5.3, acorn@^5.6.2: + version "5.7.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" + integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== + +acorn@^6.0.1, acorn@^6.0.7: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" + integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== + +address@1.0.3, address@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/address/-/address-1.0.3.tgz#b5f50631f8d6cec8bd20c963963afb55e06cbce9" + integrity sha512-z55ocwKBRLryBs394Sm3ushTtBeg6VAeuku7utSoSnsJKvKcnXFIyC6vh27n3rXyxSgkJBBCAvyOn7gSUcTYjg== + +airbnb-prop-types@^2.12.0: + version "2.13.2" + resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.13.2.tgz#43147a5062dd2a4a5600e748a47b64004cc5f7fc" + integrity sha512-2FN6DlHr6JCSxPPi25EnqGaXC4OC3/B3k1lCd6MMYrZ51/Gf/1qDfaR+JElzWa+Tl7cY2aYOlsYJGFeQyVHIeQ== + dependencies: + array.prototype.find "^2.0.4" + function.prototype.name "^1.1.0" + has "^1.0.3" + is-regex "^1.0.4" + object-is "^1.0.1" + object.assign "^4.1.0" + object.entries "^1.1.0" + prop-types "^15.7.2" + prop-types-exact "^1.2.0" + react-is "^16.8.6" + +ajv-errors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" + integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== + +ajv-keywords@^3.1.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.0.tgz#4b831e7b531415a7cc518cd404e73f6193c6349d" + integrity sha512-aUjdRFISbuFOl0EIZc+9e4FfZp0bDZgAdOOf30bJmw8VM9v84SHyVyxDfbWxpGYbdZD/9XoKxfHVNmxPkhwyGw== + +ajv@^6.1.0, ajv@^6.5.3, ajv@^6.5.5, ajv@^6.9.1: + version "6.10.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" + integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +alphanum-sort@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" + integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= + +ansi-colors@^3.0.0: + version "3.2.4" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" + integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== + +ansi-escapes@^3.0.0, ansi-escapes@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + +ansi-html@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" + integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.0.0, ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +append-transform@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-0.4.0.tgz#d76ebf8ca94d276e247a36bad44a4b74ab611991" + integrity sha1-126/jKlNJ24keja61EpLdKthGZE= + dependencies: + default-require-extensions "^1.0.0" + +aproba@^1.0.3, aproba@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +aria-query@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-3.0.0.tgz#65b3fcc1ca1155a8c9ae64d6eee297f15d5133cc" + integrity sha1-ZbP8wcoRVajJrmTW7uKX8V1RM8w= + dependencies: + ast-types-flow "0.0.7" + commander "^2.11.0" + +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8= + dependencies: + arr-flatten "^1.0.1" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.0.1, arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" + integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM= + +array-filter@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-1.0.0.tgz#baf79e62e6ef4c2a4c0b831232daffec251f9d83" + integrity sha1-uveeYubvTCpMC4MSMtr/7CUfnYM= + +array-filter@~0.0.0: + version "0.0.1" + resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec" + integrity sha1-fajPLiZijtcygDWB/SH2fKzS7uw= + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= + +array-flatten@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" + integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== + +array-includes@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d" + integrity sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.7.0" + +array-map@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662" + integrity sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI= + +array-reduce@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b" + integrity sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys= + +array-union@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= + dependencies: + array-uniq "^1.0.1" + +array-uniq@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= + +array-unique@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" + integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +array.prototype.find@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.0.4.tgz#556a5c5362c08648323ddaeb9de9d14bc1864c90" + integrity sha1-VWpcU2LAhkgyPdrrnenRS8GGTJA= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.7.0" + +array.prototype.flat@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.1.tgz#812db8f02cad24d3fab65dd67eabe3b8903494a4" + integrity sha512-rVqIs330nLJvfC7JqYvEWwqVr5QjYF1ib02i3YJtR/fICO6527Tjpc/e4Mvmxh3GIePPreRXMdaGyC99YphWEw== + dependencies: + define-properties "^1.1.2" + es-abstract "^1.10.0" + function-bind "^1.1.1" + +arrify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= + +asap@~2.0.3, asap@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + +asn1.js@^4.0.0: + version "4.10.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" + integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +assert@^1.1.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" + integrity sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE= + dependencies: + util "0.10.3" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +ast-types-flow@0.0.7, ast-types-flow@^0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" + integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= + +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + +async-each@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.2.tgz#8b8a7ca2a658f927e9f307d6d1a42f4199f0f735" + integrity sha512-6xrbvN0MOBKSJDdonmSSz2OwFSgxRaVtBDes26mj9KIGtDo+g9xosFRSC+i1gQh2oAN/tQ62AI/pGZGQjVOiRg== + +async-limiter@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" + integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== + +async@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= + +async@^2.1.4: + version "2.6.2" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381" + integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg== + dependencies: + lodash "^4.17.11" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +atob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +auth0-js@^9.8.2: + version "9.10.1" + resolved "https://registry.yarnpkg.com/auth0-js/-/auth0-js-9.10.1.tgz#91feac770006f4703ff6b3463eae41922a99d5e4" + integrity sha512-CDePT7UapFLPwp/lqOjL7kMk/lOi7h0AixurxaX+Rey4+4C0NRI+GiRR5u0dJQWSIXyKKOLieHpxzsr0c7VqSg== + dependencies: + base64-js "^1.2.0" + idtoken-verifier "^1.2.0" + js-cookie "^2.2.0" + qs "^6.4.0" + superagent "^3.8.2" + url-join "^4.0.0" + winchan "^0.2.1" + +autoprefixer@^9.4.2: + version "9.5.1" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.5.1.tgz#243b1267b67e7e947f28919d786b50d3bb0fb357" + integrity sha512-KJSzkStUl3wP0D5sdMlP82Q52JLy5+atf2MHAre48+ckWkXgixmfHyWmA77wFDy6jTHU6mIgXv6hAQ2mf1PjJQ== + dependencies: + browserslist "^4.5.4" + caniuse-lite "^1.0.30000957" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^7.0.14" + postcss-value-parser "^3.3.1" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== + +axobject-query@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.0.2.tgz#ea187abe5b9002b377f925d8bf7d1c561adf38f9" + integrity sha512-MCeek8ZH7hKyO1rWUbKNQBbl4l2eY0ntk7OGi+q0RlafrCnfPxC06WZA+uebCfmYp4mNU9jRBP1AhGyf8+W3ww== + dependencies: + ast-types-flow "0.0.7" + +babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" + integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s= + dependencies: + chalk "^1.1.3" + esutils "^2.0.2" + js-tokens "^3.0.2" + +babel-core@7.0.0-bridge.0: + version "7.0.0-bridge.0" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" + integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg== + +babel-core@^6.0.0, babel-core@^6.26.0: + version "6.26.3" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207" + integrity sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA== + dependencies: + babel-code-frame "^6.26.0" + babel-generator "^6.26.0" + babel-helpers "^6.24.1" + babel-messages "^6.23.0" + babel-register "^6.26.0" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + convert-source-map "^1.5.1" + debug "^2.6.9" + json5 "^0.5.1" + lodash "^4.17.4" + minimatch "^3.0.4" + path-is-absolute "^1.0.1" + private "^0.1.8" + slash "^1.0.0" + source-map "^0.5.7" + +babel-eslint@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-9.0.0.tgz#7d9445f81ed9f60aff38115f838970df9f2b6220" + integrity sha512-itv1MwE3TMbY0QtNfeL7wzak1mV47Uy+n6HtSOO4Xd7rvmO+tsGQSgyOEEgo6Y2vHZKZphaoelNeSVj4vkLA1g== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + eslint-scope "3.7.1" + eslint-visitor-keys "^1.0.0" + +babel-extract-comments@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/babel-extract-comments/-/babel-extract-comments-1.0.0.tgz#0a2aedf81417ed391b85e18b4614e693a0351a21" + integrity sha512-qWWzi4TlddohA91bFwgt6zO/J0X+io7Qp184Fw0m2JYRSTZnJbFR8+07KmzudHCZgOiKRCrjhylwv9Xd8gfhVQ== + dependencies: + babylon "^6.18.0" + +babel-generator@^6.18.0, babel-generator@^6.26.0: + version "6.26.1" + resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90" + integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA== + dependencies: + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + detect-indent "^4.0.0" + jsesc "^1.3.0" + lodash "^4.17.4" + source-map "^0.5.7" + trim-right "^1.0.1" + +babel-helpers@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2" + integrity sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI= + dependencies: + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-jest@23.6.0, babel-jest@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-23.6.0.tgz#a644232366557a2240a0c083da6b25786185a2f1" + integrity sha512-lqKGG6LYXYu+DQh/slrQ8nxXQkEkhugdXsU6St7GmhVS7Ilc/22ArwqXNJrf0QaOBjZB0360qZMwXqDYQHXaew== + dependencies: + babel-plugin-istanbul "^4.1.6" + babel-preset-jest "^23.2.0" + +babel-loader@8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.0.5.tgz#225322d7509c2157655840bba52e46b6c2f2fe33" + integrity sha512-NTnHnVRd2JnRqPC0vW+iOQWU5pchDbYXsG2E6DMXEpMfUcQKclF9gmf3G3ZMhzG7IG9ji4coL0cm+FxeWxDpnw== + dependencies: + find-cache-dir "^2.0.0" + loader-utils "^1.0.2" + mkdirp "^0.5.1" + util.promisify "^1.0.0" + +babel-messages@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" + integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4= + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-dynamic-import-node@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.2.0.tgz#c0adfb07d95f4a4495e9aaac6ec386c4d7c2524e" + integrity sha512-fP899ELUnTaBcIzmrW7nniyqqdYWrWuJUyPWHxFa/c7r7hS6KC8FscNfLlBNIoPSc55kYMGEEKjPjJGCLbE1qA== + dependencies: + object.assign "^4.1.0" + +babel-plugin-istanbul@^4.1.6: + version "4.1.6" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" + integrity sha512-PWP9FQ1AhZhS01T/4qLSKoHGY/xvkZdVBGlKM/HuxxS3+sC66HhTNR7+MpbO/so/cz/wY94MeSWJuP1hXIPfwQ== + dependencies: + babel-plugin-syntax-object-rest-spread "^6.13.0" + find-up "^2.1.0" + istanbul-lib-instrument "^1.10.1" + test-exclude "^4.2.1" + +babel-plugin-istanbul@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.1.1.tgz#7981590f1956d75d67630ba46f0c22493588c893" + integrity sha512-RNNVv2lsHAXJQsEJ5jonQwrJVWK8AcZpG1oxhnjCUaAjL7xahYLANhPUZbzEQHjKy1NMYUwn+0NPKQc8iSY4xQ== + dependencies: + find-up "^3.0.0" + istanbul-lib-instrument "^3.0.0" + test-exclude "^5.0.0" + +babel-plugin-jest-hoist@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" + integrity sha1-5h+uBaHKiAGq3uV6bWa4zvr0QWc= + +babel-plugin-macros@2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.5.0.tgz#01f4d3b50ed567a67b80a30b9da066e94f4097b6" + integrity sha512-BWw0lD0kVZAXRD3Od1kMrdmfudqzDzYv2qrN3l2ISR1HVp1EgLKfbOrYV9xmY5k3qx3RIu5uPAUZZZHpo0o5Iw== + dependencies: + cosmiconfig "^5.0.5" + resolve "^1.8.1" + +babel-plugin-named-asset-import@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.1.tgz#5ec13ec446d0a1e5bb6c57a1f94c9cdedb0c50d6" + integrity sha512-vzZlo+yEB5YHqI6CRRTDojeT43J3Wf3C/MVkZW5UlbSeIIVUYRKtxaFT2L/VTv9mbIyatCW39+9g/SZolvwRUQ== + +babel-plugin-syntax-object-rest-spread@^6.13.0, babel-plugin-syntax-object-rest-spread@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" + integrity sha1-/WU28rzhODb/o6VFjEkDpZe7O/U= + +babel-plugin-transform-object-rest-spread@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06" + integrity sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY= + dependencies: + babel-plugin-syntax-object-rest-spread "^6.8.0" + babel-runtime "^6.26.0" + +babel-plugin-transform-react-remove-prop-types@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" + integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== + +babel-preset-jest@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-23.2.0.tgz#8ec7a03a138f001a1a8fb1e8113652bf1a55da46" + integrity sha1-jsegOhOPABoaj7HoETZSvxpV2kY= + dependencies: + babel-plugin-jest-hoist "^23.2.0" + babel-plugin-syntax-object-rest-spread "^6.13.0" + +babel-preset-react-app@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/babel-preset-react-app/-/babel-preset-react-app-7.0.2.tgz#d01ae973edc93b9f1015cb0236dd55889a584308" + integrity sha512-mwCk/u2wuiO8qQqblN5PlDa44taY0acq7hw6W+a70W522P7a9mIcdggL1fe5/LgAT7tqCq46q9wwhqaMoYKslQ== + dependencies: + "@babel/core" "7.2.2" + "@babel/plugin-proposal-class-properties" "7.3.0" + "@babel/plugin-proposal-decorators" "7.3.0" + "@babel/plugin-proposal-object-rest-spread" "7.3.2" + "@babel/plugin-syntax-dynamic-import" "7.2.0" + "@babel/plugin-transform-classes" "7.2.2" + "@babel/plugin-transform-destructuring" "7.3.2" + "@babel/plugin-transform-flow-strip-types" "7.2.3" + "@babel/plugin-transform-react-constant-elements" "7.2.0" + "@babel/plugin-transform-react-display-name" "7.2.0" + "@babel/plugin-transform-runtime" "7.2.0" + "@babel/preset-env" "7.3.1" + "@babel/preset-react" "7.0.0" + "@babel/preset-typescript" "7.1.0" + "@babel/runtime" "7.3.1" + babel-loader "8.0.5" + babel-plugin-dynamic-import-node "2.2.0" + babel-plugin-macros "2.5.0" + babel-plugin-transform-react-remove-prop-types "0.4.24" + +babel-register@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071" + integrity sha1-btAhFz4vy0htestFxgCahW9kcHE= + dependencies: + babel-core "^6.26.0" + babel-runtime "^6.26.0" + core-js "^2.5.0" + home-or-tmp "^2.0.0" + lodash "^4.17.4" + mkdirp "^0.5.1" + source-map-support "^0.4.15" + +babel-runtime@^6.22.0, babel-runtime@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + +babel-template@^6.16.0, babel-template@^6.24.1, babel-template@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" + integrity sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI= + dependencies: + babel-runtime "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + lodash "^4.17.4" + +babel-traverse@^6.0.0, babel-traverse@^6.18.0, babel-traverse@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" + integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4= + dependencies: + babel-code-frame "^6.26.0" + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + debug "^2.6.8" + globals "^9.18.0" + invariant "^2.2.2" + lodash "^4.17.4" + +babel-types@^6.0.0, babel-types@^6.18.0, babel-types@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" + integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc= + dependencies: + babel-runtime "^6.26.0" + esutils "^2.0.2" + lodash "^4.17.4" + to-fast-properties "^1.0.3" + +babylon@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" + integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== + +bail@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3" + integrity sha512-1X8CnjFVQ+a+KW36uBNMTU5s8+v5FzeqrP7hTG5aTb4aPreSbZJlhwPon9VKMuEVgV++JM+SQrALY3kr7eswdg== + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +base64-js@^1.0.2, base64-js@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" + integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw== + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + 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" + +batch@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" + integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY= + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +bfj@6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/bfj/-/bfj-6.1.1.tgz#05a3b7784fbd72cfa3c22e56002ef99336516c48" + integrity sha512-+GUNvzHR4nRyGybQc2WpNJL4MJazMuvf92ueIyA0bIkPRwhhQu3IfZQ2PSoVPpCBJfmoSdOxu5rnotfFLlvYRQ== + dependencies: + bluebird "^3.5.1" + check-types "^7.3.0" + hoopy "^0.1.2" + tryer "^1.0.0" + +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + +binary-extensions@^1.0.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + +bluebird@^3.5.1, bluebird@^3.5.3: + version "3.5.4" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.4.tgz#d6cc661595de30d5b3af5fcedd3c0b3ef6ec5714" + integrity sha512-FG+nFEZChJrbQ9tIccIfZJBz3J7mLrAhxakAbnrJWn8d7aKOC+LWifa0G+p4ZqKp4y13T7juYvdhq9NzKdsrjw== + +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: + version "4.11.8" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" + integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== + +body-parser@1.18.3: + version "1.18.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" + integrity sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ= + dependencies: + bytes "3.0.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "~1.6.3" + iconv-lite "0.4.23" + on-finished "~2.3.0" + qs "6.5.2" + raw-body "2.3.3" + type-is "~1.6.16" + +bonjour@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" + integrity sha1-jokKGD2O6aI5OzhExpGkK897yfU= + dependencies: + array-flatten "^2.1.0" + deep-equal "^1.0.1" + dns-equal "^1.0.0" + dns-txt "^2.0.2" + multicast-dns "^6.0.1" + multicast-dns-service-types "^1.1.0" + +boolbase@^1.0.0, boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + +bootstrap@^4.1.3: + version "4.3.1" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.3.1.tgz#280ca8f610504d99d7b6b4bfc4b68cec601704ac" + integrity sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc= + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + +braces@^2.3.1, braces@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + 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" + +brorand@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + +browser-process-hrtime@^0.1.2: + version "0.1.3" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" + integrity sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw== + +browser-resolve@^1.11.3: + version "1.11.3" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6" + integrity sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ== + dependencies: + resolve "1.1.7" + +browserify-aes@^1.0.0, browserify-aes@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" + integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" + integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +browserify-rsa@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" + integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= + dependencies: + bn.js "^4.1.0" + randombytes "^2.0.1" + +browserify-sign@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" + integrity sha1-qk62jl17ZYuqa/alfmMMvXqT0pg= + dependencies: + bn.js "^4.1.1" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.2" + elliptic "^6.0.0" + inherits "^2.0.1" + parse-asn1 "^5.0.0" + +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" + integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== + dependencies: + pako "~1.0.5" + +browserslist@4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.4.1.tgz#42e828954b6b29a7a53e352277be429478a69062" + integrity sha512-pEBxEXg7JwaakBXjATYw/D1YZh4QUSCX/Mnd/wnqSRPPSi1U39iDhDoKGoBUcraKdxDlrYqJxSI5nNvD+dWP2A== + dependencies: + caniuse-lite "^1.0.30000929" + electron-to-chromium "^1.3.103" + node-releases "^1.1.3" + +browserslist@^4.0.0, browserslist@^4.3.4, browserslist@^4.3.5, browserslist@^4.5.2, browserslist@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.5.4.tgz#166c4ecef3b51737a42436ea8002aeea466ea2c7" + integrity sha512-rAjx494LMjqKnMPhFkuLmLp8JWEX0o8ADTGeAbOqaF+XCvYLreZrG5uVjnPBlAQ8REZK4pzXGvp0bWgrFtKaag== + dependencies: + caniuse-lite "^1.0.30000955" + electron-to-chromium "^1.3.122" + node-releases "^1.1.13" + +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + integrity sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk= + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +buffer-indexof@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" + integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= + +buffer@^4.3.0: + version "4.9.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" + integrity sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg= + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= + +bytes@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" + integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= + +cacache@^11.0.2: + version "11.3.2" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-11.3.2.tgz#2d81e308e3d258ca38125b676b98b2ac9ce69bfa" + integrity sha512-E0zP4EPGDOaT2chM08Als91eYnf8Z+eH1awwwVsngUmgppfM5jjJ8l3z5vO5p5w/I3LsiXawb1sW0VY65pQABg== + dependencies: + bluebird "^3.5.3" + chownr "^1.1.1" + figgy-pudding "^3.5.1" + glob "^7.1.3" + graceful-fs "^4.1.15" + lru-cache "^5.1.1" + mississippi "^3.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.2" + ssri "^6.0.1" + unique-filename "^1.1.1" + y18n "^4.0.0" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + 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" + +call-me-maybe@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" + integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= + +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= + dependencies: + callsites "^2.0.0" + +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= + dependencies: + caller-callsite "^2.0.0" + +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camel-case@3.0.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" + integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M= + dependencies: + no-case "^2.2.0" + upper-case "^1.1.1" + +camelcase@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" + integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +caniuse-api@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" + integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== + dependencies: + browserslist "^4.0.0" + caniuse-lite "^1.0.0" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000918, caniuse-lite@^1.0.30000929, caniuse-lite@^1.0.30000955, caniuse-lite@^1.0.30000957: + version "1.0.30000957" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000957.tgz#fb1026bf184d7d62c685205358c3b24b9e29f7b3" + integrity sha512-8wxNrjAzyiHcLXN/iunskqQnJquQQ6VX8JHfW5kLgAPRSiSuKZiNfmIkP5j7jgyXqAQBSoXyJxfnbCFS0ThSiQ== + +capture-exit@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-1.2.0.tgz#1c5fcc489fd0ab00d4f1ac7ae1072e3173fbab6f" + integrity sha1-HF/MSJ/QqwDU8ax64QcuMXP7q28= + dependencies: + rsvp "^3.3.3" + +capture-exit@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" + integrity sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g== + dependencies: + rsvp "^4.8.4" + +case-sensitive-paths-webpack-plugin@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.2.0.tgz#3371ef6365ef9c25fa4b81c16ace0e9c7dc58c3e" + integrity sha512-u5ElzokS8A1pm9vM3/iDgTcI3xqHxuCao94Oz8etI3cf0Tio0p8izkDYbTIn09uP3yUUr6+veaE6IkjnTYS46g== + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +ccount@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.3.tgz#f1cec43f332e2ea5a569fd46f9f5bde4e6102aff" + integrity sha512-Jt9tIBkRc9POUof7QA/VwWd+58fKkEEfI+/t1/eOlxKM7ZhrczNzMFefge7Ai+39y1pR/pP6cI19guHy3FSLmw== + +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + +check-types@^7.3.0: + version "7.4.0" + resolved "https://registry.yarnpkg.com/check-types/-/check-types-7.4.0.tgz#0378ec1b9616ec71f774931a3c6516fad8c152f4" + integrity sha512-YbulWHdfP99UfZ73NcUDlNJhEIDgm9Doq9GhpyXbF+7Aegi3CVV7qqMCKTTqJxlvEvnQBp9IA+dxsGN6xK/nSg== + +cheerio@^1.0.0-rc.2: + version "1.0.0-rc.3" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.3.tgz#094636d425b2e9c0f4eb91a46c05630c9a1a8bf6" + integrity sha512-0td5ijfUPuubwLUu0OBoe98gZj8C/AA+RW3v67GPlGOrvxWjZmBXiBCRU+I8VEiNyJzjth40POfHiz2RB3gImA== + dependencies: + css-select "~1.2.0" + dom-serializer "~0.1.1" + entities "~1.1.1" + htmlparser2 "^3.9.1" + lodash "^4.15.0" + parse5 "^3.0.1" + +chokidar@^2.0.0, chokidar@^2.0.2, chokidar@^2.0.4: + version "2.1.5" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.5.tgz#0ae8434d962281a5f56c72869e79cb6d9d86ad4d" + integrity sha512-i0TprVWp+Kj4WRPtInjexJ8Q+BqTE909VpH8xVhXrJkoc5QC8VO9TryGOqTr+2hljzc1sC62t22h5tZePodM/A== + dependencies: + anymatch "^2.0.0" + async-each "^1.0.1" + braces "^2.3.2" + glob-parent "^3.1.0" + inherits "^2.0.3" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^3.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.2.1" + upath "^1.1.1" + optionalDependencies: + fsevents "^1.2.7" + +chownr@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" + integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g== + +chrome-trace-event@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.0.tgz#45a91bd2c20c9411f0963b5aaeb9a1b95e09cc48" + integrity sha512-xDbVgyfDTT2piup/h8dK/y4QZfJRSa73bw1WZ8b4XM1o7fsFubUVGYcE+1ANtOzJJELGpYoG2961z0Z6OAld9A== + dependencies: + tslib "^1.9.0" + +ci-info@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" + integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +circular-json-es6@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/circular-json-es6/-/circular-json-es6-2.0.2.tgz#e4f4a093e49fb4b6aba1157365746112a78bd344" + integrity sha512-ODYONMMNb3p658Zv+Pp+/XPa5s6q7afhz3Tzyvo+VRh9WIrJ64J76ZC4GQxnlye/NesTn09jvOiuE8+xxfpwhQ== + +circular-json@^0.3.1: + version "0.3.3" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" + integrity sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A== + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +classnames@^2.2.3: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== + +clean-css@4.2.x: + version "4.2.1" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17" + integrity sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g== + dependencies: + source-map "~0.6.0" + +cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-width@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" + integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= + +cliui@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" + integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + wrap-ansi "^2.0.0" + +clone-deep@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.2.4.tgz#4e73dd09e9fb971cc38670c5dced9c1896481cc6" + integrity sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY= + dependencies: + for-own "^0.1.3" + is-plain-object "^2.0.1" + kind-of "^3.0.2" + lazy-cache "^1.0.3" + shallow-clone "^0.1.2" + +clone-deep@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713" + integrity sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ== + dependencies: + for-own "^1.0.0" + is-plain-object "^2.0.4" + kind-of "^6.0.0" + shallow-clone "^1.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +coa@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/coa/-/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" + integrity sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA== + dependencies: + "@types/q" "^1.5.1" + chalk "^2.4.1" + q "^1.1.2" + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0, color-convert@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.5.2: + version "1.5.3" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" + integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.1.0.tgz#d8e9fb096732875774c84bf922815df0308d0ffc" + integrity sha512-CwyopLkuRYO5ei2EpzpIh6LqJMt6Mt+jZhO5VI5f/wJLZriXQE32/SSqzmrh+QB+AZT81Cj8yv+7zwToW8ahZg== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" + integrity sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w== + dependencies: + delayed-stream "~1.0.0" + +comma-separated-tokens@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.5.tgz#b13793131d9ea2d2431cf5b507ddec258f0ce0db" + integrity sha512-Cg90/fcK93n0ecgYTAz1jaA3zvnQ0ExlmKY1rdbyHqAx6BHxwoJc+J7HDu0iuQ7ixEs1qaa+WyQ6oeuBpYP1iA== + dependencies: + trim "0.0.1" + +commander@2.17.x: + version "2.17.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" + integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== + +commander@^2.11.0, commander@^2.19.0, commander@~2.20.0: + version "2.20.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" + integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== + +commander@~2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" + integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== + +common-tags@^1.4.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" + integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + +component-emitter@^1.2.0, component-emitter@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= + +compressible@~2.0.16: + version "2.0.16" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.16.tgz#a49bf9858f3821b64ce1be0296afc7380466a77f" + integrity sha512-JQfEOdnI7dASwCuSPWIeVYwc/zMsu/+tRhoUvEfXz2gxOA2DNjmG5vhtFdBlhWPPGo+RdT9S3tgc/uH5qgDiiA== + dependencies: + mime-db ">= 1.38.0 < 2" + +compression@^1.5.2: + version "1.7.4" + resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" + integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== + dependencies: + accepts "~1.3.5" + bytes "3.0.0" + compressible "~2.0.16" + debug "2.6.9" + on-headers "~1.0.2" + safe-buffer "5.1.2" + vary "~1.1.2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@^1.5.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +confusing-browser-globals@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.6.tgz#5918188e8244492cdd46d6be1cab60edef3063ce" + integrity sha512-GzyX86c2TvaagAOR+lHL2Yq4T4EnoBcnojZBcNbxVKSunxmGTnioXHR5Mo2ha/XnCoQw8eurvj6Ta+SwPEPkKg== + +connect-history-api-fallback@^1.3.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" + integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== + +console-browserify@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" + integrity sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA= + dependencies: + date-now "^0.1.4" + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= + +contains-path@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" + integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= + +content-disposition@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" + integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= + +content-type@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + +convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" + integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== + dependencies: + safe-buffer "~5.1.1" + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= + +cookie@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" + integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= + +cookiejar@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" + integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== + +copy-concurrently@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" + integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A== + dependencies: + aproba "^1.1.1" + fs-write-stream-atomic "^1.0.8" + iferr "^0.1.5" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.0" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +core-js-compat@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.0.1.tgz#bff73ba31ca8687431b9c88f78d3362646fb76f0" + integrity sha512-2pC3e+Ht/1/gD7Sim/sqzvRplMiRnFQVlPpDVaHtY9l7zZP7knamr3VRD6NyGfHd84MrDC0tAM9ulNxYMW0T3g== + dependencies: + browserslist "^4.5.4" + core-js "3.0.1" + core-js-pure "3.0.1" + semver "^6.0.0" + +core-js-pure@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.0.1.tgz#37358fb0d024e6b86d443d794f4e37e949098cbe" + integrity sha512-mSxeQ6IghKW3MoyF4cz19GJ1cMm7761ON+WObSyLfTu/Jn3x7w4NwNFnrZxgl4MTSvYYepVLNuRtlB4loMwJ5g== + +core-js@2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.4.tgz#b8897c062c4d769dd30a0ac5c73976c47f92ea0d" + integrity sha512-05qQ5hXShcqGkPZpXEFLIpxayZscVD2kuMBZewxiIPPEagukO4mqgPA9CWhUvFBJfy3ODdK2p9xyHh7FTU9/7A== + +core-js@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.0.1.tgz#1343182634298f7f38622f95e73f54e48ddf4738" + integrity sha512-sco40rF+2KlE0ROMvydjkrVMMG1vYilP2ALoRXcYR4obqbYIuV3Bg+51GEDW+HF8n7NRA+iaA4qD0nD9lo9mew== + +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= + +core-js@^2.4.0, core-js@^2.5.0: + version "2.6.5" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.5.tgz#44bc8d249e7fb2ff5d00e0341a7ffb94fbf67895" + integrity sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A== + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cosmiconfig@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-4.0.0.tgz#760391549580bbd2df1e562bc177b13c290972dc" + integrity sha512-6e5vDdrXZD+t5v0L8CrurPeybg4Fmf+FCSYxXKYVAqLUtyCSbuyqE059d0kDthTNRzKVjL7QMgNpEUlsoYH3iQ== + dependencies: + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + require-from-string "^2.0.1" + +cosmiconfig@^5.0.0, cosmiconfig@^5.0.5, cosmiconfig@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.0.tgz#45038e4d28a7fe787203aede9c25bca4a08b12c8" + integrity sha512-nxt+Nfc3JAqf4WIWd0jXLjTJZmsPLrA9DDc4nRw2KFJQJK7DNooqSXrNI7tzLG50CF8axczly5UV929tBmh/7g== + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.13.0" + parse-json "^4.0.0" + +create-ecdh@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" + integrity sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw== + dependencies: + bn.js "^4.1.0" + elliptic "^6.0.0" + +create-hash@^1.1.0, create-hash@^1.1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +create-react-context@^0.2.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.3.tgz#9ec140a6914a22ef04b8b09b7771de89567cb6f3" + integrity sha512-CQBmD0+QGgTaxDL3OX1IDXYqjkp2It4RIbcb99jS6AEg27Ga+a9G3JtK6SIu0HBwPLZlmwt9F7UwWA4Bn92Rag== + dependencies: + fbjs "^0.8.0" + gud "^1.0.0" + +cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + +crypto-browserify@^3.11.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" + integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== + dependencies: + browserify-cipher "^1.0.0" + browserify-sign "^4.0.0" + create-ecdh "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.0" + diffie-hellman "^5.0.0" + inherits "^2.0.1" + pbkdf2 "^3.0.3" + public-encrypt "^4.0.0" + randombytes "^2.0.0" + randomfill "^1.0.3" + +crypto-js@^3.1.9-1: + version "3.1.9-1" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8" + integrity sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg= + +css-blank-pseudo@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" + integrity sha512-LHz35Hr83dnFeipc7oqFDmsjHdljj3TQtxGGiNWSOsTLIAubSm4TEz8qCaKFpk7idaQ1GfWscF4E6mgpBysA1w== + dependencies: + postcss "^7.0.5" + +css-color-names@0.0.4, css-color-names@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= + +css-declaration-sorter@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" + integrity sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== + dependencies: + postcss "^7.0.1" + timsort "^0.3.0" + +css-has-pseudo@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-0.10.0.tgz#3c642ab34ca242c59c41a125df9105841f6966ee" + integrity sha512-Z8hnfsZu4o/kt+AuFzeGpLVhFOGO9mluyHBaA2bA8aCGTwah5sT3WV/fTHH8UNZUytOIImuGPrl/prlb4oX4qQ== + dependencies: + postcss "^7.0.6" + postcss-selector-parser "^5.0.0-rc.4" + +css-loader@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-1.0.0.tgz#9f46aaa5ca41dbe31860e3b62b8e23c42916bf56" + integrity sha512-tMXlTYf3mIMt3b0dDCOQFJiVvxbocJ5Ho577WiGPYPZcqVEO218L2iU22pDXzkTZCLDE+9AmGSUkWxeh/nZReA== + dependencies: + babel-code-frame "^6.26.0" + css-selector-tokenizer "^0.7.0" + icss-utils "^2.1.0" + loader-utils "^1.0.2" + lodash.camelcase "^4.3.0" + postcss "^6.0.23" + postcss-modules-extract-imports "^1.2.0" + postcss-modules-local-by-default "^1.2.0" + postcss-modules-scope "^1.1.0" + postcss-modules-values "^1.3.0" + postcss-value-parser "^3.3.0" + source-list-map "^2.0.0" + +css-prefers-color-scheme@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-3.1.1.tgz#6f830a2714199d4f0d0d0bb8a27916ed65cff1f4" + integrity sha512-MTu6+tMs9S3EUqzmqLXEcgNRbNkkD/TGFvowpeoWJn5Vfq7FMgsmRQs9X5NXAURiOBmOxm/lLjsDNXDE6k9bhg== + dependencies: + postcss "^7.0.5" + +css-select-base-adapter@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" + integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== + +css-select@^1.1.0, css-select@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" + integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= + dependencies: + boolbase "~1.0.0" + css-what "2.1" + domutils "1.5.1" + nth-check "~1.0.1" + +css-select@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.0.2.tgz#ab4386cec9e1f668855564b17c3733b43b2a5ede" + integrity sha512-dSpYaDVoWaELjvZ3mS6IKZM/y2PMPa/XYoEfYNZePL4U/XgyxZNroHEHReDx/d+VgXh9VbCTtFqLkFbmeqeaRQ== + dependencies: + boolbase "^1.0.0" + css-what "^2.1.2" + domutils "^1.7.0" + nth-check "^1.0.2" + +css-selector-tokenizer@^0.7.0: + version "0.7.1" + resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.1.tgz#a177271a8bca5019172f4f891fc6eed9cbf68d5d" + integrity sha512-xYL0AMZJ4gFzJQsHUKa5jiWWi2vH77WVNg7JYRyewwj6oPh4yb/y6Y9ZCw9dsj/9UauMhtuxR+ogQd//EdEVNA== + dependencies: + cssesc "^0.1.0" + fastparse "^1.1.1" + regexpu-core "^1.0.0" + +css-tree@1.0.0-alpha.28: + version "1.0.0-alpha.28" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.28.tgz#8e8968190d886c9477bc8d61e96f61af3f7ffa7f" + integrity sha512-joNNW1gCp3qFFzj4St6zk+Wh/NBv0vM5YbEreZk0SD4S23S+1xBKb6cLDg2uj4P4k/GUMlIm6cKIDqIG+vdt0w== + dependencies: + mdn-data "~1.1.0" + source-map "^0.5.3" + +css-tree@1.0.0-alpha.29: + version "1.0.0-alpha.29" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.29.tgz#3fa9d4ef3142cbd1c301e7664c1f352bd82f5a39" + integrity sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg== + dependencies: + mdn-data "~1.1.0" + source-map "^0.5.3" + +css-unit-converter@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.1.tgz#d9b9281adcfd8ced935bdbaba83786897f64e996" + integrity sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY= + +css-url-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/css-url-regex/-/css-url-regex-1.1.0.tgz#83834230cc9f74c457de59eebd1543feeb83b7ec" + integrity sha1-g4NCMMyfdMRX3lnuvRVD/uuDt+w= + +css-what@2.1, css-what@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" + integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== + +cssdb@^4.3.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-4.4.0.tgz#3bf2f2a68c10f5c6a08abd92378331ee803cddb0" + integrity sha512-LsTAR1JPEM9TpGhl/0p3nQecC2LJ0kD8X5YARu1hk/9I1gril5vDtMZyNxcEpxxDj34YNck/ucjuoUd66K03oQ== + +cssesc@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" + integrity sha1-yBSQPkViM3GgR3tAEJqq++6t27Q= + +cssesc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-2.0.0.tgz#3b13bd1bb1cb36e1bcb5a4dcd27f54c5dcb35703" + integrity sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg== + +cssnano-preset-default@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" + integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== + dependencies: + css-declaration-sorter "^4.0.1" + cssnano-util-raw-cache "^4.0.1" + postcss "^7.0.0" + postcss-calc "^7.0.1" + postcss-colormin "^4.0.3" + postcss-convert-values "^4.0.1" + postcss-discard-comments "^4.0.2" + postcss-discard-duplicates "^4.0.2" + postcss-discard-empty "^4.0.1" + postcss-discard-overridden "^4.0.1" + postcss-merge-longhand "^4.0.11" + postcss-merge-rules "^4.0.3" + postcss-minify-font-values "^4.0.2" + postcss-minify-gradients "^4.0.2" + postcss-minify-params "^4.0.2" + postcss-minify-selectors "^4.0.2" + postcss-normalize-charset "^4.0.1" + postcss-normalize-display-values "^4.0.2" + postcss-normalize-positions "^4.0.2" + postcss-normalize-repeat-style "^4.0.2" + postcss-normalize-string "^4.0.2" + postcss-normalize-timing-functions "^4.0.2" + postcss-normalize-unicode "^4.0.1" + postcss-normalize-url "^4.0.1" + postcss-normalize-whitespace "^4.0.2" + postcss-ordered-values "^4.1.2" + postcss-reduce-initial "^4.0.3" + postcss-reduce-transforms "^4.0.2" + postcss-svgo "^4.0.2" + postcss-unique-selectors "^4.0.1" + +cssnano-util-get-arguments@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" + integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= + +cssnano-util-get-match@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" + integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= + +cssnano-util-raw-cache@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" + integrity sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== + dependencies: + postcss "^7.0.0" + +cssnano-util-same-parent@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" + integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== + +cssnano@^4.1.0: + version "4.1.10" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2" + integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== + dependencies: + cosmiconfig "^5.0.0" + cssnano-preset-default "^4.0.7" + is-resolvable "^1.0.0" + postcss "^7.0.0" + +csso@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/csso/-/csso-3.5.1.tgz#7b9eb8be61628973c1b261e169d2f024008e758b" + integrity sha512-vrqULLffYU1Q2tLdJvaCYbONStnfkfimRxXNaGjxMldI0C7JPBC4rB1RyjhfdZ4m1frm8pM9uRPKH3d2knZ8gg== + dependencies: + css-tree "1.0.0-alpha.29" + +cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": + version "0.3.6" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.6.tgz#f85206cee04efa841f3c5982a74ba96ab20d65ad" + integrity sha512-DtUeseGk9/GBW0hl0vVPpU22iHL6YB5BUX7ml1hB+GMpo0NX5G4voX3kdWiMSEguFtcW3Vh3djqNF4aIe6ne0A== + +cssstyle@^1.0.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.2.2.tgz#427ea4d585b18624f6fdbf9de7a2a1a3ba713077" + integrity sha512-43wY3kl1CVQSvL7wUY1qXkxVGkStjpkDmVjiIKX8R97uhajy8Bybay78uOtqvh7Q5GK75dNPfW0geWjE6qQQow== + dependencies: + cssom "0.3.x" + +cyclist@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" + integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA= + +damerau-levenshtein@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514" + integrity sha1-AxkcQyy27qFou3fzpV/9zLiXhRQ= + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +data-urls@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" + integrity sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ== + dependencies: + abab "^2.0.0" + whatwg-mimetype "^2.2.0" + whatwg-url "^7.0.0" + +date-now@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" + integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= + +debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^3.1.0, debug@^3.2.5, debug@^3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + +debug@^4.0.1, debug@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +decamelize@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decamelize@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-2.0.0.tgz#656d7bbc8094c4c788ea53c5840908c9c7d063c7" + integrity sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg== + dependencies: + xregexp "4.0.0" + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +deep-diff@^0.3.5: + version "0.3.8" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" + integrity sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ= + +deep-equal-ident@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-equal-ident/-/deep-equal-ident-1.1.1.tgz#06f4b89e53710cd6cea4a7781c7a956642de8dc9" + integrity sha1-BvS4nlNxDNbOpKd4HHqVZkLejck= + dependencies: + lodash.isequal "^3.0" + +deep-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +default-gateway@^2.6.0: + version "2.7.2" + resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-2.7.2.tgz#b7ef339e5e024b045467af403d50348db4642d0f" + integrity sha512-lAc4i9QJR0YHSDFdzeBQKfZ1SRDG3hsJNEkrpcZa8QhBfidLAilT60BDEIVUUGqosFp425KOgB3uYqcnQrWafQ== + dependencies: + execa "^0.10.0" + ip-regex "^2.1.0" + +default-require-extensions@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-1.0.0.tgz#f37ea15d3e13ffd9b437d33e1a75b5fb97874cb8" + integrity sha1-836hXT4T/9m0N9M+GnW1+5eHTLg= + dependencies: + strip-bom "^2.0.0" + +define-properties@^1.1.2, define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +del@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" + integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU= + dependencies: + globby "^6.1.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + p-map "^1.1.1" + pify "^3.0.0" + rimraf "^2.2.8" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +des.js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" + integrity sha1-wHTS4qpqipoH29YfmhXCzYPsjsw= + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +destroy@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + +detect-indent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" + integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg= + dependencies: + repeating "^2.0.0" + +detect-libc@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +detect-newline@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + +detect-node@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" + integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== + +detect-port-alt@1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" + integrity sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== + dependencies: + address "^1.0.1" + debug "^2.6.0" + +diff@^3.2.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + +diffie-hellman@^5.0.0: + version "5.0.3" + resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" + integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + +dir-glob@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034" + integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag== + dependencies: + arrify "^1.0.1" + path-type "^3.0.0" + +discontinuous-range@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" + integrity sha1-44Mx8IRLukm5qctxx3FYWqsbxlo= + +dns-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" + integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= + +dns-packet@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.1.tgz#12aa426981075be500b910eedcd0b47dd7deda5a" + integrity sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg== + dependencies: + ip "^1.1.0" + safe-buffer "^5.0.1" + +dns-txt@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" + integrity sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY= + dependencies: + buffer-indexof "^1.0.0" + +doctrine@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" + integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo= + dependencies: + esutils "^2.0.2" + isarray "^1.0.0" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +dom-converter@^0.2: + version "0.2.0" + resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" + integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== + dependencies: + utila "~0.4" + +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + +dom-serializer@0, dom-serializer@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" + integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== + dependencies: + domelementtype "^1.3.0" + entities "^1.1.1" + +domain-browser@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" + integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== + +domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== + dependencies: + webidl-conversions "^4.0.2" + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domutils@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" + integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= + dependencies: + dom-serializer "0" + domelementtype "1" + +domutils@^1.5.1, domutils@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-prop@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" + integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== + dependencies: + is-obj "^1.0.0" + +dotenv-expand@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-4.2.0.tgz#def1f1ca5d6059d24a766e587942c21106ce1275" + integrity sha1-3vHxyl1gWdJKdm5YeULCEQbOEnU= + +dotenv@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.0.0.tgz#24e37c041741c5f4b25324958ebbc34bca965935" + integrity sha512-FlWbnhgjtwD+uNLUGHbMykMOYQaTivdHEmYwAKFjn6GKe/CqY0fNae93ZHTd20snh9ZLr8mTzIL9m0APQ1pjQg== + +duplexer@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" + integrity sha1-rOb/gIwc5mtX0ev5eXessCM0z8E= + +duplexify@^3.4.2, duplexify@^3.6.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" + integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== + dependencies: + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + +electron-to-chromium@^1.3.103, electron-to-chromium@^1.3.122: + version "1.3.124" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.124.tgz#861fc0148748a11b3e5ccebdf8b795ff513fa11f" + integrity sha512-glecGr/kFdfeXUHOHAWvGcXrxNU+1wSO/t5B23tT1dtlvYB26GY8aHzZSWD7HqhqC800Lr+w/hQul6C5AF542w== + +elliptic@^6.0.0: + version "6.4.1" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a" + integrity sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ== + dependencies: + bn.js "^4.4.0" + brorand "^1.0.1" + hash.js "^1.0.0" + hmac-drbg "^1.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.0" + +emoji-regex@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" + integrity sha512-PAHp6TxrCy7MGMFidro8uikr+zlJJKJ/Q6mm2ExZ7HwkyR9lSVFfE3kt36qcwa24BQL7y0G9axycGjK1A/0uNQ== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= + dependencies: + iconv-lite "~0.4.13" + +end-of-stream@^1.0.0, end-of-stream@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" + integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== + dependencies: + once "^1.4.0" + +enhanced-resolve@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" + integrity sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng== + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.4.0" + tapable "^1.0.0" + +entities@^1.1.1, entities@~1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + +enzyme-adapter-react-16@^1.7.0: + version "1.12.1" + resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.12.1.tgz#6a2d74c80559d35ac0a91ca162fa45f4186290cf" + integrity sha512-GB61gvY97XvrA6qljExGY+lgI6BBwz+ASLaRKct9VQ3ozu0EraqcNn3CcrUckSGIqFGa1+CxO5gj5is5t3lwrw== + dependencies: + enzyme-adapter-utils "^1.11.0" + object.assign "^4.1.0" + object.values "^1.1.0" + prop-types "^15.7.2" + react-is "^16.8.6" + react-test-renderer "^16.0.0-0" + semver "^5.6.0" + +enzyme-adapter-utils@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.11.0.tgz#6ffff782b1b57dd46c72a845a91fc4103956a117" + integrity sha512-0VZeoE9MNx+QjTfsjmO1Mo+lMfunucYB4wt5ficU85WB/LoetTJrbuujmHP3PJx6pSoaAuLA+Mq877x4LoxdNg== + dependencies: + airbnb-prop-types "^2.12.0" + function.prototype.name "^1.1.0" + object.assign "^4.1.0" + object.fromentries "^2.0.0" + prop-types "^15.7.2" + semver "^5.6.0" + +enzyme-matchers@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/enzyme-matchers/-/enzyme-matchers-7.0.2.tgz#fe47f2cb54f8b0c5a3f0e515faf8131c188f613a" + integrity sha512-hsWb4Mw+F0eJ9q6ljTK2RqLwsrdoKfr5IXmKtDaTPeOEFXsVl6L2RX2REdevvn2KJfSxkcKaCGwPldOSc1l9tQ== + dependencies: + circular-json-es6 "^2.0.1" + deep-equal-ident "^1.1.1" + +enzyme-to-json@^3.3.0: + version "3.3.5" + resolved "https://registry.yarnpkg.com/enzyme-to-json/-/enzyme-to-json-3.3.5.tgz#f8eb82bd3d5941c9d8bc6fd9140030777d17d0af" + integrity sha512-DmH1wJ68HyPqKSYXdQqB33ZotwfUhwQZW3IGXaNXgR69Iodaoj8TF/D9RjLdz4pEhGq2Tx2zwNUIjBuqoZeTgA== + dependencies: + lodash "^4.17.4" + +enzyme@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.9.0.tgz#2b491f06ca966eb56b6510068c7894a7e0be3909" + integrity sha512-JqxI2BRFHbmiP7/UFqvsjxTirWoM1HfeaJrmVSZ9a1EADKkZgdPcAuISPMpoUiHlac9J4dYt81MC5BBIrbJGMg== + dependencies: + array.prototype.flat "^1.2.1" + cheerio "^1.0.0-rc.2" + function.prototype.name "^1.1.0" + has "^1.0.3" + html-element-map "^1.0.0" + is-boolean-object "^1.0.0" + is-callable "^1.1.4" + is-number-object "^1.0.3" + is-regex "^1.0.4" + is-string "^1.0.4" + is-subset "^0.1.1" + lodash.escape "^4.0.1" + lodash.isequal "^4.5.0" + object-inspect "^1.6.0" + object-is "^1.0.1" + object.assign "^4.1.0" + object.entries "^1.0.4" + object.values "^1.0.4" + raf "^3.4.0" + rst-selector-parser "^2.2.3" + string.prototype.trim "^1.1.2" + +errno@^0.1.3, errno@~0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" + integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== + dependencies: + prr "~1.0.1" + +error-ex@^1.2.0, error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.10.0, es-abstract@^1.11.0, es-abstract@^1.12.0, es-abstract@^1.5.0, es-abstract@^1.5.1, es-abstract@^1.7.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" + integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg== + dependencies: + es-to-primitive "^1.2.0" + function-bind "^1.1.1" + has "^1.0.3" + is-callable "^1.1.4" + is-regex "^1.0.4" + object-keys "^1.0.12" + +es-to-primitive@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" + integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + +escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@^1.9.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.1.tgz#c485ff8d6b4cdb89e27f4a856e91f118401ca510" + integrity sha512-JwiqFD9KdGVVpeuRa68yU3zZnBEOcPs0nKW7wZzXky8Z7tffdYUHbe11bPCV5jYlK6DVdKLWLm0f5I/QlL0Kmw== + dependencies: + esprima "^3.1.3" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +eslint-config-react-app@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-3.0.8.tgz#6f606828ba30bafee7d744c41cd07a3fea8f3035" + integrity sha512-Ovi6Bva67OjXrom9Y/SLJRkrGqKhMAL0XCH8BizPhjEVEhYczl2ZKiNZI2CuqO5/CJwAfMwRXAVGY0KToWr1aA== + dependencies: + confusing-browser-globals "^1.0.6" + +eslint-import-resolver-node@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a" + integrity sha512-sfmTqJfPSizWu4aymbPr4Iidp5yKm8yDkHp+Ir3YiTHiiDfxh69mOUsmiqW6RZ9zRXFaF64GtYmN7e+8GHBv6Q== + dependencies: + debug "^2.6.9" + resolve "^1.5.0" + +eslint-loader@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/eslint-loader/-/eslint-loader-2.1.1.tgz#2a9251523652430bfdd643efdb0afc1a2a89546a" + integrity sha512-1GrJFfSevQdYpoDzx8mEE2TDWsb/zmFuY09l6hURg1AeFIKQOvZ+vH0UPjzmd1CZIbfTV5HUkMeBmFiDBkgIsQ== + dependencies: + loader-fs-cache "^1.0.0" + loader-utils "^1.0.2" + object-assign "^4.0.1" + object-hash "^1.1.4" + rimraf "^2.6.1" + +eslint-module-utils@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.3.0.tgz#546178dab5e046c8b562bbb50705e2456d7bda49" + integrity sha512-lmDJgeOOjk8hObTysjqH7wyMi+nsHwwvfBykwfhjR1LNdd7C2uFJBvx4OpWYpXOw4df1yE1cDEVd1yLHitk34w== + dependencies: + debug "^2.6.8" + pkg-dir "^2.0.0" + +eslint-plugin-flowtype@2.50.1: + version "2.50.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.50.1.tgz#36d4c961ac8b9e9e1dc091d3fba0537dad34ae8a" + integrity sha512-9kRxF9hfM/O6WGZcZPszOVPd2W0TLHBtceulLTsGfwMPtiCCLnCW0ssRiOOiXyqrCA20pm1iXdXm7gQeN306zQ== + dependencies: + lodash "^4.17.10" + +eslint-plugin-import@2.14.0: + version "2.14.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.14.0.tgz#6b17626d2e3e6ad52cfce8807a845d15e22111a8" + integrity sha512-FpuRtniD/AY6sXByma2Wr0TXvXJ4nA/2/04VPlfpmUDPOpOY264x+ILiwnrk/k4RINgDAyFZByxqPUbSQ5YE7g== + dependencies: + contains-path "^0.1.0" + debug "^2.6.8" + doctrine "1.5.0" + eslint-import-resolver-node "^0.3.1" + eslint-module-utils "^2.2.0" + has "^1.0.1" + lodash "^4.17.4" + minimatch "^3.0.3" + read-pkg-up "^2.0.0" + resolve "^1.6.0" + +eslint-plugin-jsx-a11y@6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.1.2.tgz#69bca4890b36dcf0fe16dd2129d2d88b98f33f88" + integrity sha512-7gSSmwb3A+fQwtw0arguwMdOdzmKUgnUcbSNlo+GjKLAQFuC2EZxWqG9XHRI8VscBJD5a8raz3RuxQNFW+XJbw== + dependencies: + aria-query "^3.0.0" + array-includes "^3.0.3" + ast-types-flow "^0.0.7" + axobject-query "^2.0.1" + damerau-levenshtein "^1.0.4" + emoji-regex "^6.5.1" + has "^1.0.3" + jsx-ast-utils "^2.0.1" + +eslint-plugin-react@7.12.4: + version "7.12.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.12.4.tgz#b1ecf26479d61aee650da612e425c53a99f48c8c" + integrity sha512-1puHJkXJY+oS1t467MjbqjvX53uQ05HXwjqDgdbGBqf5j9eeydI54G3KwiJmWciQ0HTBacIKw2jgwSBSH3yfgQ== + dependencies: + array-includes "^3.0.3" + doctrine "^2.1.0" + has "^1.0.3" + jsx-ast-utils "^2.0.1" + object.fromentries "^2.0.0" + prop-types "^15.6.2" + resolve "^1.9.0" + +eslint-scope@3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8" + integrity sha1-PWPD7f2gLgbgGkUq2IyqzHzctug= + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-scope@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" + integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-utils@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512" + integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q== + +eslint-visitor-keys@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" + integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== + +eslint@5.12.0: + version "5.12.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.12.0.tgz#fab3b908f60c52671fb14e996a450b96c743c859" + integrity sha512-LntwyPxtOHrsJdcSwyQKVtHofPHdv+4+mFwEe91r2V13vqpM8yLr7b1sW+Oo/yheOPkWYsYlYJCkzlFAt8KV7g== + dependencies: + "@babel/code-frame" "^7.0.0" + ajv "^6.5.3" + chalk "^2.1.0" + cross-spawn "^6.0.5" + debug "^4.0.1" + doctrine "^2.1.0" + eslint-scope "^4.0.0" + eslint-utils "^1.3.1" + eslint-visitor-keys "^1.0.0" + espree "^5.0.0" + esquery "^1.0.1" + esutils "^2.0.2" + file-entry-cache "^2.0.0" + functional-red-black-tree "^1.0.1" + glob "^7.1.2" + globals "^11.7.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + inquirer "^6.1.0" + js-yaml "^3.12.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.3.0" + lodash "^4.17.5" + minimatch "^3.0.4" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + optionator "^0.8.2" + path-is-inside "^1.0.2" + pluralize "^7.0.0" + progress "^2.0.0" + regexpp "^2.0.1" + semver "^5.5.1" + strip-ansi "^4.0.0" + strip-json-comments "^2.0.1" + table "^5.0.2" + text-table "^0.2.0" + +espree@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-5.0.1.tgz#5d6526fa4fc7f0788a5cf75b15f30323e2f81f7a" + integrity sha512-qWAZcWh4XE/RwzLJejfcofscgMc9CamR6Tn1+XRXNzrvUSSbiAjGOI/fggztjIi7y9VLPqnICMIPiGyr8JaZ0A== + dependencies: + acorn "^6.0.7" + acorn-jsx "^5.0.0" + eslint-visitor-keys "^1.0.0" + +esprima@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" + integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" + integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA== + dependencies: + estraverse "^4.0.0" + +esrecurse@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf" + integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== + dependencies: + estraverse "^4.1.0" + +estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" + integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= + +esutils@^2.0.0, esutils@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" + integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + +eventemitter3@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" + integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA== + +events@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88" + integrity sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA== + +eventsource@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0" + integrity sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ== + dependencies: + original "^1.0.0" + +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +exec-sh@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36" + integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw== + dependencies: + merge "^1.2.0" + +exec-sh@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.2.tgz#6738de2eb7c8e671d0366aea0b0db8c6f7d7391b" + integrity sha512-9sLAvzhI5nc8TpuQUh4ahMdCrWT00wPWz7j47/emR5+2qEfoZP5zzUXvx+vdx+H6ohhnsYC31iX04QLYJK8zTg== + +execa@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" + integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw== + dependencies: + cross-spawn "^6.0.0" + get-stream "^3.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" + +execa@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" + integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.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" + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + 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" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s= + dependencies: + is-posix-bracket "^0.1.0" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + 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" + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc= + dependencies: + fill-range "^2.1.0" + +expect@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-23.6.0.tgz#1e0c8d3ba9a581c87bd71fb9bc8862d443425f98" + integrity sha512-dgSoOHgmtn/aDGRVFWclQyPDKl2CQRq0hmIEoUAuQs/2rn2NcvCWcSCovm6BLeuB/7EZuLGu2QfnR+qRt5OM4w== + dependencies: + ansi-styles "^3.2.0" + jest-diff "^23.6.0" + jest-get-type "^22.1.0" + jest-matcher-utils "^23.6.0" + jest-message-util "^23.4.0" + jest-regex-util "^23.3.0" + +express@^4.16.2: + version "4.16.4" + resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e" + integrity sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg== + dependencies: + accepts "~1.3.5" + array-flatten "1.1.1" + body-parser "1.18.3" + content-disposition "0.5.2" + content-type "~1.0.4" + cookie "0.3.1" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~1.1.2" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.1.1" + fresh "0.5.2" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "~2.3.0" + parseurl "~1.3.2" + path-to-regexp "0.1.7" + proxy-addr "~2.0.4" + qs "6.5.2" + range-parser "~1.2.0" + safe-buffer "5.1.2" + send "0.16.2" + serve-static "1.13.2" + setprototypeof "1.1.0" + statuses "~1.4.0" + type-is "~1.6.16" + utils-merge "1.0.1" + vary "~1.1.2" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@^3.0.0, extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +external-editor@^3.0.0, external-editor@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" + integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE= + dependencies: + is-extglob "^1.0.0" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + 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" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= + +fast-glob@^2.0.2: + version "2.2.6" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.6.tgz#a5d5b697ec8deda468d85a74035290a025a95295" + integrity sha512-0BvMaZc1k9F+MeWWMe8pL6YltFzZYcJsYU7D4JyDA6PAczaXvxqQQ/z+mDF7/4Mw01DeUc+i3CTKajnkANkV4w== + dependencies: + "@mrmlnc/readdir-enhanced" "^2.2.1" + "@nodelib/fs.stat" "^1.1.2" + glob-parent "^3.1.0" + is-glob "^4.0.0" + merge2 "^1.2.3" + micromatch "^3.1.10" + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= + +fast-levenshtein@~2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fastparse@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9" + integrity sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ== + +faye-websocket@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" + integrity sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= + dependencies: + websocket-driver ">=0.5.1" + +faye-websocket@~0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.1.tgz#f0efe18c4f56e4f40afc7e06c719fd5ee6188f38" + integrity sha1-8O/hjE9W5PQK/H4Gxxn9XuYYjzg= + dependencies: + websocket-driver ">=0.5.1" + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + integrity sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg= + dependencies: + bser "^2.0.0" + +fbjs@^0.8.0: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + +figgy-pudding@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" + integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== + +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= + dependencies: + escape-string-regexp "^1.0.5" + +file-entry-cache@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" + integrity sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E= + dependencies: + flat-cache "^1.2.1" + object-assign "^4.0.1" + +file-loader@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-2.0.0.tgz#39749c82f020b9e85901dcff98e8004e6401cfde" + integrity sha512-YCsBfd1ZGCyonOKLxPiKPdu+8ld9HAaMEvJewzz+b2eTF7uL5Zm/HdBF6FjCrpCMRq25Mi0U1gl4pwn2TlH7hQ== + dependencies: + loader-utils "^1.0.2" + schema-utils "^1.0.0" + +filename-regex@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" + integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY= + +fileset@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0" + integrity sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA= + dependencies: + glob "^7.0.3" + minimatch "^3.0.3" + +filesize@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" + integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg== + +fill-range@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" + integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q== + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^3.0.0" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +finalhandler@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105" + integrity sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.2" + statuses "~1.4.0" + unpipe "~1.0.0" + +find-cache-dir@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-0.1.1.tgz#c8defae57c8a52a8a784f9e31c57c742e993a0b9" + integrity sha1-yN765XyKUqinhPnjHFfHQumToLk= + dependencies: + commondir "^1.0.1" + mkdirp "^0.5.1" + pkg-dir "^1.0.0" + +find-cache-dir@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" + integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== + dependencies: + commondir "^1.0.1" + make-dir "^2.0.0" + pkg-dir "^3.0.0" + +find-up@3.0.0, find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +find-up@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" + integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8= + dependencies: + path-exists "^2.0.0" + pinkie-promise "^2.0.0" + +find-up@^2.0.0, find-up@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" + integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= + dependencies: + locate-path "^2.0.0" + +flat-cache@^1.2.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.4.tgz#2c2ef77525cc2929007dfffa1dd314aa9c9dee6f" + integrity sha512-VwyB3Lkgacfik2vhqR4uv2rvebqmDvFu4jlN/C1RzWoJEo8I7z4Q404oiqYCkq41mni8EzQnm95emU9seckwtg== + dependencies: + circular-json "^0.3.1" + graceful-fs "^4.1.2" + rimraf "~2.6.2" + write "^0.2.1" + +flatten@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" + integrity sha1-2uRqnXj74lKSJYzB54CkHZXAN4I= + +flush-write-stream@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" + integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== + dependencies: + inherits "^2.0.3" + readable-stream "^2.3.6" + +follow-redirects@^1.0.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76" + integrity sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ== + dependencies: + debug "^3.2.6" + +for-in@^0.1.3: + version "0.1.8" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" + integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE= + +for-in@^1.0.1, for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +for-own@^0.1.3, for-own@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= + dependencies: + for-in "^1.0.1" + +for-own@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" + integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs= + dependencies: + for-in "^1.0.1" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +fork-ts-checker-webpack-plugin@1.0.0-alpha.6: + version "1.0.0-alpha.6" + resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-1.0.0-alpha.6.tgz#826c57048addf8a3253853615c84f3ff7beeaf45" + integrity sha512-s/V+58nLrUjuXyzYk8AL11XG8bxIirTbafDLMn26sL59HQx8QvvsRTqOkhq4MV0coIkog1jZuH/E9Abm8zFZ2g== + dependencies: + babel-code-frame "^6.22.0" + chalk "^2.4.1" + chokidar "^2.0.4" + micromatch "^3.1.10" + minimatch "^3.0.4" + semver "^5.6.0" + tapable "^1.0.0" + +form-data@^2.3.1, form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +formidable@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.1.tgz#70fb7ca0290ee6ff961090415f4b3df3d2082659" + integrity sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg== + +forwarded@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" + integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + +from2@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + +fs-extra@7.0.1, fs-extra@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-extra@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" + integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-minipass@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d" + integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ== + dependencies: + minipass "^2.2.1" + +fs-write-stream-atomic@^1.0.8: + version "1.0.10" + resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" + integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" + integrity sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg== + dependencies: + nan "^2.9.2" + node-pre-gyp "^0.10.0" + +fsevents@^1.2.3, fsevents@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" + integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== + dependencies: + nan "^2.9.2" + node-pre-gyp "^0.10.0" + +function-bind@^1.0.2, function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +function.prototype.name@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.0.tgz#8bd763cc0af860a859cc5d49384d74b932cd2327" + integrity sha512-Bs0VRrTz4ghD8pTmbJQD1mZ8A/mN0ur/jGz+A6FBxPDUPkm1tNfF6bhTYPA7i7aF4lZJVr+OXTNNrnnIl58Wfg== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + is-callable "^1.1.3" + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +get-caller-file@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" + integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== + +get-own-enumerable-property-symbols@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.0.tgz#b877b49a5c16aefac3655f2ed2ea5b684df8d203" + integrity sha512-CIJYJC4GGF06TakLg8z4GQKvDsx9EMspVxOYih7LerEL/WosUnFIww45CGfxfeKHqlg3twgUrYRT1O3WQqjGCg== + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= + dependencies: + is-glob "^2.0.0" + +glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-to-regexp@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" + integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= + +glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== + dependencies: + 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" + +global-modules@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" + integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + dependencies: + global-prefix "^3.0.0" + +global-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" + integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + dependencies: + ini "^1.3.5" + kind-of "^6.0.2" + which "^1.3.1" + +globals@^11.1.0, globals@^11.7.0: + version "11.11.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.11.0.tgz#dcf93757fa2de5486fbeed7118538adf789e9c2e" + integrity sha512-WHq43gS+6ufNOEqlrDBxVEbb8ntfXrfAUU2ZOpCxrBdGKW3gyv8mCxAfIBD0DroPKGrJ2eSsXsLtY9MPntsyTw== + +globals@^9.18.0: + version "9.18.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" + integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ== + +globby@8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d" + integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w== + dependencies: + array-union "^1.0.1" + dir-glob "2.0.0" + fast-glob "^2.0.2" + glob "^7.1.2" + ignore "^3.3.5" + pify "^3.0.0" + slash "^1.0.0" + +globby@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" + integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw= + dependencies: + array-union "^1.0.1" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6: + version "4.1.15" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" + integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== + +growly@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= + +gud@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" + integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== + +gzip-size@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.0.0.tgz#a55ecd99222f4c48fd8c01c625ce3b349d0a0e80" + integrity sha512-5iI7omclyqrnWw4XbXAmGhPsABkSIDQonv2K0h61lybgofWa6iZyvrI3r2zsJH4P8Nb64fFVzlvfhs0g7BBxAA== + dependencies: + duplexer "^0.1.1" + pify "^3.0.0" + +handle-thing@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754" + integrity sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ== + +handlebars@^4.0.3: + version "4.1.1" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.1.1.tgz#6e4e41c18ebe7719ae4d38e5aca3d32fa3dd23d3" + integrity sha512-3Zhi6C0euYZL5sM0Zcy7lInLXKQ+YLcF/olbN010mzGQ4XVm50JeyBnMqofHh696GrciGruC7kCcApPDJvVgwA== + dependencies: + neo-async "^2.6.0" + optimist "^0.6.1" + source-map "^0.6.1" + optionalDependencies: + uglify-js "^3.1.4" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.0: + version "5.1.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" + integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== + dependencies: + ajv "^6.5.5" + har-schema "^2.0.0" + +harmony-reflect@^1.4.6: + version "1.6.1" + resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.1.tgz#c108d4f2bb451efef7a37861fdbdae72c9bdefa9" + integrity sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA== + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo= + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" + integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.0, has@^1.0.1, has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hash-base@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" + integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg= + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hast-util-from-parse5@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-5.0.0.tgz#a505a05766e0f96e389bfb0b1dd809eeefcef47b" + integrity sha512-A7ev5OseS/J15214cvDdcI62uwovJO2PB60Xhnq7kaxvvQRFDEccuqbkrFXU03GPBGopdPqlpQBRqIcDS/Fjbg== + dependencies: + ccount "^1.0.3" + hastscript "^5.0.0" + property-information "^5.0.0" + web-namespaces "^1.1.2" + xtend "^4.0.1" + +hast-util-parse-selector@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.1.tgz#4ddbae1ae12c124e3eb91b581d2556441766f0ab" + integrity sha512-Xyh0v+nHmQvrOqop2Jqd8gOdyQtE8sIP9IQf7mlVDqp924W4w/8Liuguk2L2qei9hARnQSG2m+wAOCxM7npJVw== + +hastscript@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-5.0.0.tgz#fee10382c1bc4ba3f1be311521d368c047d2c43a" + integrity sha512-xJtuJ8D42Xtq5yJrnDg/KAIxl2cXBXKoiIJwmWX9XMf8113qHTGl/Bf7jEsxmENJ4w6q4Tfl8s/Y6mEZo8x8qw== + dependencies: + comma-separated-tokens "^1.0.0" + hast-util-parse-selector "^2.2.0" + property-information "^5.0.1" + space-separated-tokens "^1.0.0" + +he@1.2.x: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +hex-color-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" + integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== + +history@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/history/-/history-4.9.0.tgz#84587c2068039ead8af769e9d6a6860a14fa1bca" + integrity sha512-H2DkjCjXf0Op9OAr6nJ56fcRkTSNrUiv41vNJ6IswJjif6wlpZK0BTfFbi7qK9dXLSYZxkq5lBsj3vUjlYBYZA== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^2.2.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^0.4.0" + +hmac-drbg@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +hoek@4.x.x: + version "4.2.1" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb" + integrity sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA== + +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" + integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA== + dependencies: + react-is "^16.7.0" + +home-or-tmp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" + integrity sha1-42w/LSyufXRqhX440Y1fMqeILbg= + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.1" + +hoopy@^0.1.2: + version "0.1.4" + resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" + integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== + +hosted-git-info@^2.1.4: + version "2.7.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" + integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w== + +hpack.js@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" + integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI= + dependencies: + inherits "^2.0.1" + obuf "^1.0.0" + readable-stream "^2.0.1" + wbuf "^1.1.0" + +hsl-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" + integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= + +hsla-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" + integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= + +html-comment-regex@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" + integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== + +html-element-map@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/html-element-map/-/html-element-map-1.0.1.tgz#3c4fcb4874ebddfe4283b51c8994e7713782b592" + integrity sha512-BZSfdEm6n706/lBfXKWa4frZRZcT5k1cOusw95ijZsHlI+GdgY0v95h6IzO3iIDf2ROwq570YTwqNPqHcNMozw== + dependencies: + array-filter "^1.0.0" + +html-encoding-sniffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" + integrity sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw== + dependencies: + whatwg-encoding "^1.0.1" + +html-entities@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" + integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= + +html-minifier@^3.2.3: + version "3.5.21" + resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c" + integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA== + dependencies: + camel-case "3.0.x" + clean-css "4.2.x" + commander "2.17.x" + he "1.2.x" + param-case "2.1.x" + relateurl "0.2.x" + uglify-js "3.4.x" + +html-webpack-plugin@4.0.0-alpha.2: + version "4.0.0-alpha.2" + resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.0.0-alpha.2.tgz#7745967e389a57a098e26963f328ebe4c19b598d" + integrity sha512-tyvhjVpuGqD7QYHi1l1drMQTg5i+qRxpQEGbdnYFREgOKy7aFDf/ocQ/V1fuEDlQx7jV2zMap3Hj2nE9i5eGXw== + dependencies: + "@types/tapable" "1.0.2" + html-minifier "^3.2.3" + loader-utils "^1.1.0" + lodash "^4.17.10" + pretty-error "^2.0.2" + tapable "^1.0.0" + util.promisify "1.0.0" + +htmlparser2@^3.3.0, htmlparser2@^3.9.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +http-deceiver@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" + integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= + +http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + +http-parser-js@>=0.4.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.0.tgz#d65edbede84349d0dc30320815a15d39cc3cbbd8" + integrity sha512-cZdEF7r4gfRIq7ezX9J0T+kQmJNOub71dWbgAXVHDct80TKP4MCETtZQ31xyv38UwgzkWPYF/Xc0ge55dW9Z9w== + +http-proxy-middleware@~0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz#0987e6bb5a5606e5a69168d8f967a87f15dd8aab" + integrity sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q== + dependencies: + http-proxy "^1.16.2" + is-glob "^4.0.0" + lodash "^4.17.5" + micromatch "^3.1.9" + +http-proxy@^1.16.2: + version "1.17.0" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" + integrity sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g== + dependencies: + eventemitter3 "^3.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" + integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= + +humps@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao= + +iconv-lite@0.4.23: + version "0.4.23" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" + integrity sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +icss-replace-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" + integrity sha1-Bupvg2ead0njhs/h/oEq5dsiPe0= + +icss-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962" + integrity sha1-g/Cg7DeL8yRheLbCrZE28TWxyWI= + dependencies: + postcss "^6.0.1" + +identity-obj-proxy@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14" + integrity sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ= + dependencies: + harmony-reflect "^1.4.6" + +idtoken-verifier@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/idtoken-verifier/-/idtoken-verifier-1.2.0.tgz#4654f1f07ab7a803fc9b1b8b36057e2a87ad8b09" + integrity sha512-8jmmFHwdPz8L73zGNAXHHOV9yXNC+Z0TUBN5rafpoaFaLFltlIFr1JkQa3FYAETP23eSsulVw0sBiwrE8jqbUg== + dependencies: + base64-js "^1.2.0" + crypto-js "^3.1.9-1" + jsbn "^0.1.0" + superagent "^3.8.2" + url-join "^1.1.0" + +ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + +iferr@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= + +ignore-walk@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" + integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== + dependencies: + minimatch "^3.0.4" + +ignore@^3.3.5: + version "3.3.10" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" + integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug== + +ignore@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== + +immer@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" + integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== + +import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" + integrity sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk= + dependencies: + import-from "^2.1.0" + +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + +import-fresh@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.0.0.tgz#a3d897f420cab0e671236897f75bc14b4885c390" + integrity sha512-pOnA9tfM3Uwics+SaBLCNyZZZbK+4PTu0OPZtLlMIrv17EdBoC15S9Kn8ckJ9TZTyKb3ywNE5y1yeDxxGA7nTQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-from@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" + integrity sha1-M1238qev/VOqpHHUuAId7ja387E= + dependencies: + resolve-from "^3.0.0" + +import-local@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" + integrity sha512-vAaZHieK9qjGo58agRBg+bhHX3hoTZU/Oa3GESWLz7t1U62fk63aHuDJJEteXoDeTCcPmUT+z38gkHPZkkmpmQ== + dependencies: + pkg-dir "^2.0.0" + resolve-cwd "^2.0.0" + +import-local@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" + integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== + dependencies: + pkg-dir "^3.0.0" + resolve-cwd "^2.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc= + +indexof@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" + integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + +ini@^1.3.5, ini@~1.3.0: + version "1.3.5" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" + integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + +inquirer@6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.1.tgz#9943fc4882161bdb0b0c9276769c75b32dbfcd52" + integrity sha512-088kl3DRT2dLU5riVMKKr1DlImd6X7smDhpXUCkJDCKvTEJeRiXh0G132HG9u5a+6Ylw9plFRY7RuTnwohYSpg== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.0" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.0" + figures "^2.0.0" + lodash "^4.17.10" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.1.0" + string-width "^2.1.0" + strip-ansi "^5.0.0" + through "^2.3.6" + +inquirer@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.2.tgz#46941176f65c9eb20804627149b743a218f25406" + integrity sha512-Z2rREiXA6cHRR9KBOarR3WuLlFzlIfAEIiB45ll5SSadMg7WqOh1MKEjjndfuH5ewXdixWCxqnVfGOQzPeiztA== + dependencies: + ansi-escapes "^3.2.0" + chalk "^2.4.2" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.3" + figures "^2.0.0" + lodash "^4.17.11" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.4.0" + string-width "^2.1.0" + strip-ansi "^5.0.0" + through "^2.3.6" + +internal-ip@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-3.0.1.tgz#df5c99876e1d2eb2ea2d74f520e3f669a00ece27" + integrity sha512-NXXgESC2nNVtU+pqmC9e6R8B1GpKxzsAQhffvh5AL79qKnodd+L7tnEQmTiUAVngqLalPbSqRA7XGIEL5nCd0Q== + dependencies: + default-gateway "^2.6.0" + ipaddr.js "^1.5.2" + +invariant@^2.2.2, invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= + +invert-kv@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" + integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== + +ip-regex@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" + integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= + +ip@^1.1.0, ip@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" + integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + +ipaddr.js@1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e" + integrity sha1-6qM9bd16zo9/b+DJygRA5wZzix4= + +ipaddr.js@^1.5.2: + version "1.9.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65" + integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA== + +is-absolute-url@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" + integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= + dependencies: + binary-extensions "^1.0.0" + +is-boolean-object@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.0.tgz#98f8b28030684219a95f375cfbd88ce3405dff93" + integrity sha1-mPiygDBoQhmpXzdc+9iM40Bd/5M= + +is-buffer@^1.0.2, is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-buffer@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725" + integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw== + +is-callable@^1.1.3, is-callable@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" + integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== + +is-ci@^1.0.10: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" + integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== + dependencies: + ci-info "^1.5.0" + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-color-stop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" + integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= + dependencies: + css-color-names "^0.0.4" + hex-color-regex "^1.1.0" + hsl-regex "^1.0.0" + hsla-regex "^1.0.0" + rgb-regex "^1.0.1" + rgba-regex "^1.0.0" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" + integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY= + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= + +is-dotfile@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" + integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE= + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ= + dependencies: + is-primitive "^2.0.0" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-finite@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" + integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-generator-fn@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-1.0.0.tgz#969d49e1bb3329f6bb7f09089be26578b2ddd46a" + integrity sha1-lp1J4bszKfa7fwkIm+JleLLd1Go= + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= + dependencies: + is-extglob "^1.0.0" + +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + dependencies: + is-extglob "^2.1.1" + +is-number-object@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.3.tgz#f265ab89a9f445034ef6aff15a8f00b00f551799" + integrity sha1-8mWrian0RQNO9q/xWo8AsA9VF5k= + +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8= + dependencies: + kind-of "^3.0.2" + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-number@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" + integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== + +is-obj@^1.0.0, is-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + +is-path-cwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" + integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0= + +is-path-in-cwd@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52" + integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ== + dependencies: + is-path-inside "^1.0.0" + +is-path-inside@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" + integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= + dependencies: + path-is-inside "^1.0.1" + +is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q= + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= + +is-promise@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" + integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= + +is-regex@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" + integrity sha1-VRdIm1RwkbCTDglWVM7SXul+lJE= + dependencies: + has "^1.0.1" + +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= + +is-resolvable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" + integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== + +is-root@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.0.0.tgz#838d1e82318144e5a6f77819d90207645acc7019" + integrity sha512-F/pJIk8QD6OX5DNhRB7hWamLsUilmkDGho48KbgZ6xg/lmAZXHxzXQ91jzB3yRSw5kdQGGGc4yz8HYhTYIMWPg== + +is-stream@^1.0.1, is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-string@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.4.tgz#cc3a9b69857d621e963725a24caeec873b826e64" + integrity sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ= + +is-subset@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" + integrity sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY= + +is-svg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75" + integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== + dependencies: + html-comment-regex "^1.1.0" + +is-symbol@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" + integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== + dependencies: + has-symbols "^1.0.0" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-utf8@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" + integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isemail@3.x.x: + version "3.2.0" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-3.2.0.tgz#59310a021931a9fb06bbb51e155ce0b3f236832c" + integrity sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg== + dependencies: + punycode "2.x.x" + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +istanbul-api@^1.3.1: + version "1.3.7" + resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.3.7.tgz#a86c770d2b03e11e3f778cd7aedd82d2722092aa" + integrity sha512-4/ApBnMVeEPG3EkSzcw25wDe4N66wxwn+KKn6b47vyek8Xb3NBAcg4xfuQbS7BqcZuTX4wxfD5lVagdggR3gyA== + dependencies: + async "^2.1.4" + fileset "^2.0.2" + istanbul-lib-coverage "^1.2.1" + istanbul-lib-hook "^1.2.2" + istanbul-lib-instrument "^1.10.2" + istanbul-lib-report "^1.1.5" + istanbul-lib-source-maps "^1.2.6" + istanbul-reports "^1.5.1" + js-yaml "^3.7.0" + mkdirp "^0.5.1" + once "^1.4.0" + +istanbul-lib-coverage@^1.2.0, istanbul-lib-coverage@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz#ccf7edcd0a0bb9b8f729feeb0930470f9af664f0" + integrity sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ== + +istanbul-lib-coverage@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#0b891e5ad42312c2b9488554f603795f9a2211ba" + integrity sha512-dKWuzRGCs4G+67VfW9pBFFz2Jpi4vSp/k7zBcJ888ofV5Mi1g5CUML5GvMvV6u9Cjybftu+E8Cgp+k0dI1E5lw== + +istanbul-lib-hook@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.2.2.tgz#bc6bf07f12a641fbf1c85391d0daa8f0aea6bf86" + integrity sha512-/Jmq7Y1VeHnZEQ3TL10VHyb564mn6VrQXHchON9Jf/AEcmQ3ZIiyD1BVzNOKTZf/G3gE+kiGK6SmpF9y3qGPLw== + dependencies: + append-transform "^0.4.0" + +istanbul-lib-instrument@^1.10.1, istanbul-lib-instrument@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.2.tgz#1f55ed10ac3c47f2bdddd5307935126754d0a9ca" + integrity sha512-aWHxfxDqvh/ZlxR8BBaEPVSWDPUkGD63VjGQn3jcw8jCp7sHEMKcrj4xfJn/ABzdMEHiQNyvDQhqm5o8+SQg7A== + dependencies: + babel-generator "^6.18.0" + babel-template "^6.16.0" + babel-traverse "^6.18.0" + babel-types "^6.18.0" + babylon "^6.18.0" + istanbul-lib-coverage "^1.2.1" + semver "^5.3.0" + +istanbul-lib-instrument@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.1.0.tgz#a2b5484a7d445f1f311e93190813fa56dfb62971" + integrity sha512-ooVllVGT38HIk8MxDj/OIHXSYvH+1tq/Vb38s8ixt9GoJadXska4WkGY+0wkmtYCZNYtaARniH/DixUGGLZ0uA== + dependencies: + "@babel/generator" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + istanbul-lib-coverage "^2.0.3" + semver "^5.5.0" + +istanbul-lib-report@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.5.tgz#f2a657fc6282f96170aaf281eb30a458f7f4170c" + integrity sha512-UsYfRMoi6QO/doUshYNqcKJqVmFe9w51GZz8BS3WB0lYxAllQYklka2wP9+dGZeHYaWIdcXUx8JGdbqaoXRXzw== + dependencies: + istanbul-lib-coverage "^1.2.1" + mkdirp "^0.5.1" + path-parse "^1.0.5" + supports-color "^3.1.2" + +istanbul-lib-source-maps@^1.2.4, istanbul-lib-source-maps@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.6.tgz#37b9ff661580f8fca11232752ee42e08c6675d8f" + integrity sha512-TtbsY5GIHgbMsMiRw35YBHGpZ1DVFEO19vxxeiDMYaeOFOCzfnYVxvl6pOUIZR4dtPhAGpSMup8OyF8ubsaqEg== + dependencies: + debug "^3.1.0" + istanbul-lib-coverage "^1.2.1" + mkdirp "^0.5.1" + rimraf "^2.6.1" + source-map "^0.5.3" + +istanbul-reports@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.5.1.tgz#97e4dbf3b515e8c484caea15d6524eebd3ff4e1a" + integrity sha512-+cfoZ0UXzWjhAdzosCPP3AN8vvef8XDkWtTfgaN+7L3YTpNYITnCaEkceo5SEYy644VkHka/P1FvkWvrG/rrJw== + dependencies: + handlebars "^4.0.3" + +jest-changed-files@^23.4.2: + version "23.4.2" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-23.4.2.tgz#1eed688370cd5eebafe4ae93d34bb3b64968fe83" + integrity sha512-EyNhTAUWEfwnK0Is/09LxoqNDOn7mU7S3EHskG52djOFS/z+IT0jT3h3Ql61+dklcG7bJJitIWEMB4Sp1piHmA== + dependencies: + throat "^4.0.0" + +jest-cli@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-23.6.0.tgz#61ab917744338f443ef2baa282ddffdd658a5da4" + integrity sha512-hgeD1zRUp1E1zsiyOXjEn4LzRLWdJBV//ukAHGlx6s5mfCNJTbhbHjgxnDUXA8fsKWN/HqFFF6X5XcCwC/IvYQ== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.1" + exit "^0.1.2" + glob "^7.1.2" + graceful-fs "^4.1.11" + import-local "^1.0.0" + is-ci "^1.0.10" + istanbul-api "^1.3.1" + istanbul-lib-coverage "^1.2.0" + istanbul-lib-instrument "^1.10.1" + istanbul-lib-source-maps "^1.2.4" + jest-changed-files "^23.4.2" + jest-config "^23.6.0" + jest-environment-jsdom "^23.4.0" + jest-get-type "^22.1.0" + jest-haste-map "^23.6.0" + jest-message-util "^23.4.0" + jest-regex-util "^23.3.0" + jest-resolve-dependencies "^23.6.0" + jest-runner "^23.6.0" + jest-runtime "^23.6.0" + jest-snapshot "^23.6.0" + jest-util "^23.4.0" + jest-validate "^23.6.0" + jest-watcher "^23.4.0" + jest-worker "^23.2.0" + micromatch "^2.3.11" + node-notifier "^5.2.1" + prompts "^0.1.9" + realpath-native "^1.0.0" + rimraf "^2.5.4" + slash "^1.0.0" + string-length "^2.0.0" + strip-ansi "^4.0.0" + which "^1.2.12" + yargs "^11.0.0" + +jest-config@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-23.6.0.tgz#f82546a90ade2d8c7026fbf6ac5207fc22f8eb1d" + integrity sha512-i8V7z9BeDXab1+VNo78WM0AtWpBRXJLnkT+lyT+Slx/cbP5sZJ0+NDuLcmBE5hXAoK0aUp7vI+MOxR+R4d8SRQ== + dependencies: + babel-core "^6.0.0" + babel-jest "^23.6.0" + chalk "^2.0.1" + glob "^7.1.1" + jest-environment-jsdom "^23.4.0" + jest-environment-node "^23.4.0" + jest-get-type "^22.1.0" + jest-jasmine2 "^23.6.0" + jest-regex-util "^23.3.0" + jest-resolve "^23.6.0" + jest-util "^23.4.0" + jest-validate "^23.6.0" + micromatch "^2.3.11" + pretty-format "^23.6.0" + +jest-diff@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-23.6.0.tgz#1500f3f16e850bb3d71233408089be099f610c7d" + integrity sha512-Gz9l5Ov+X3aL5L37IT+8hoCUsof1CVYBb2QEkOupK64XyRR3h+uRpYIm97K7sY8diFxowR8pIGEdyfMKTixo3g== + dependencies: + chalk "^2.0.1" + diff "^3.2.0" + jest-get-type "^22.1.0" + pretty-format "^23.6.0" + +jest-docblock@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-23.2.0.tgz#f085e1f18548d99fdd69b20207e6fd55d91383a7" + integrity sha1-8IXh8YVI2Z/dabICB+b9VdkTg6c= + dependencies: + detect-newline "^2.1.0" + +jest-each@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-23.6.0.tgz#ba0c3a82a8054387016139c733a05242d3d71575" + integrity sha512-x7V6M/WGJo6/kLoissORuvLIeAoyo2YqLOoCDkohgJ4XOXSqOtyvr8FbInlAWS77ojBsZrafbozWoKVRdtxFCg== + dependencies: + chalk "^2.0.1" + pretty-format "^23.6.0" + +jest-environment-enzyme@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/jest-environment-enzyme/-/jest-environment-enzyme-7.0.2.tgz#7af83556e9a894d350d0dd4e561eaa33254e1daf" + integrity sha512-cBBWX3ZCR4JJR6ml6cTYLH1fdBZ2QEbywY6yrkgTP6OZgE+Nh6Agu0g99uJha2F36+zRFS/xkk8AIEAJvLtzQQ== + dependencies: + jest-environment-jsdom "^24.0.0" + +jest-environment-jsdom@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-23.4.0.tgz#056a7952b3fea513ac62a140a2c368c79d9e6023" + integrity sha1-BWp5UrP+pROsYqFAosNox52eYCM= + dependencies: + jest-mock "^23.2.0" + jest-util "^23.4.0" + jsdom "^11.5.1" + +jest-environment-jsdom@^24.0.0: + version "24.7.1" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-24.7.1.tgz#a40e004b4458ebeb8a98082df135fd501b9fbbd6" + integrity sha512-Gnhb+RqE2JuQGb3kJsLF8vfqjt3PHKSstq4Xc8ic+ax7QKo4Z0RWGucU3YV+DwKR3T9SYc+3YCUQEJs8r7+Jxg== + dependencies: + "@jest/environment" "^24.7.1" + "@jest/fake-timers" "^24.7.1" + "@jest/types" "^24.7.0" + jest-mock "^24.7.0" + jest-util "^24.7.1" + jsdom "^11.5.1" + +jest-environment-node@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-23.4.0.tgz#57e80ed0841dea303167cce8cd79521debafde10" + integrity sha1-V+gO0IQd6jAxZ8zozXlSHeuv3hA= + dependencies: + jest-mock "^23.2.0" + jest-util "^23.4.0" + +jest-enzyme@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/jest-enzyme/-/jest-enzyme-7.0.2.tgz#706af4d713846d5cb78c24830cf3deed9bb029a1" + integrity sha512-7N9YhFmyPkDcVyvifyWePkJaZQX/wON4D6uD8qmGSc2ZaXjj+fkEPMaZ1+Kqnp75FTHaJZ+NVigpmoQiys+J2w== + dependencies: + enzyme-matchers "^7.0.2" + enzyme-to-json "^3.3.0" + jest-environment-enzyme "^7.0.2" + +jest-get-type@^22.1.0: + version "22.4.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.4.3.tgz#e3a8504d8479342dd4420236b322869f18900ce4" + integrity sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w== + +jest-haste-map@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-23.6.0.tgz#2e3eb997814ca696d62afdb3f2529f5bbc935e16" + integrity sha512-uyNhMyl6dr6HaXGHp8VF7cK6KpC6G9z9LiMNsst+rJIZ8l7wY0tk8qwjPmEghczojZ2/ZhtEdIabZ0OQRJSGGg== + dependencies: + fb-watchman "^2.0.0" + graceful-fs "^4.1.11" + invariant "^2.2.4" + jest-docblock "^23.2.0" + jest-serializer "^23.0.1" + jest-worker "^23.2.0" + micromatch "^2.3.11" + sane "^2.0.0" + +jest-haste-map@^24.7.1: + version "24.7.1" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.7.1.tgz#772e215cd84080d4bbcb759cfb668ad649a21471" + integrity sha512-g0tWkzjpHD2qa03mTKhlydbmmYiA2KdcJe762SbfFo/7NIMgBWAA0XqQlApPwkWOF7Cxoi/gUqL0i6DIoLpMBw== + dependencies: + "@jest/types" "^24.7.0" + anymatch "^2.0.0" + fb-watchman "^2.0.0" + graceful-fs "^4.1.15" + invariant "^2.2.4" + jest-serializer "^24.4.0" + jest-util "^24.7.1" + jest-worker "^24.6.0" + micromatch "^3.1.10" + sane "^4.0.3" + walker "^1.0.7" + optionalDependencies: + fsevents "^1.2.7" + +jest-jasmine2@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-23.6.0.tgz#840e937f848a6c8638df24360ab869cc718592e0" + integrity sha512-pe2Ytgs1nyCs8IvsEJRiRTPC0eVYd8L/dXJGU08GFuBwZ4sYH/lmFDdOL3ZmvJR8QKqV9MFuwlsAi/EWkFUbsQ== + dependencies: + babel-traverse "^6.0.0" + chalk "^2.0.1" + co "^4.6.0" + expect "^23.6.0" + is-generator-fn "^1.0.0" + jest-diff "^23.6.0" + jest-each "^23.6.0" + jest-matcher-utils "^23.6.0" + jest-message-util "^23.4.0" + jest-snapshot "^23.6.0" + jest-util "^23.4.0" + pretty-format "^23.6.0" + +jest-leak-detector@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-23.6.0.tgz#e4230fd42cf381a1a1971237ad56897de7e171de" + integrity sha512-f/8zA04rsl1Nzj10HIyEsXvYlMpMPcy0QkQilVZDFOaPbv2ur71X5u2+C4ZQJGyV/xvVXtCCZ3wQ99IgQxftCg== + dependencies: + pretty-format "^23.6.0" + +jest-matcher-utils@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz#726bcea0c5294261a7417afb6da3186b4b8cac80" + integrity sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog== + dependencies: + chalk "^2.0.1" + jest-get-type "^22.1.0" + pretty-format "^23.6.0" + +jest-message-util@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-23.4.0.tgz#17610c50942349508d01a3d1e0bda2c079086a9f" + integrity sha1-F2EMUJQjSVCNAaPR4L2iwHkIap8= + dependencies: + "@babel/code-frame" "^7.0.0-beta.35" + chalk "^2.0.1" + micromatch "^2.3.11" + slash "^1.0.0" + stack-utils "^1.0.1" + +jest-message-util@^24.7.1: + version "24.7.1" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.7.1.tgz#f1dc3a6c195647096a99d0f1dadbc447ae547018" + integrity sha512-dk0gqVtyqezCHbcbk60CdIf+8UHgD+lmRHifeH3JRcnAqh4nEyPytSc9/L1+cQyxC+ceaeP696N4ATe7L+omcg== + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/test-result" "^24.7.1" + "@jest/types" "^24.7.0" + "@types/stack-utils" "^1.0.1" + chalk "^2.0.1" + micromatch "^3.1.10" + slash "^2.0.0" + stack-utils "^1.0.1" + +jest-mock@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-23.2.0.tgz#ad1c60f29e8719d47c26e1138098b6d18b261134" + integrity sha1-rRxg8p6HGdR8JuETgJi20YsmETQ= + +jest-mock@^24.7.0: + version "24.7.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-24.7.0.tgz#e49ce7262c12d7f5897b0d8af77f6db8e538023b" + integrity sha512-6taW4B4WUcEiT2V9BbOmwyGuwuAFT2G8yghF7nyNW1/2gq5+6aTqSPcS9lS6ArvEkX55vbPAS/Jarx5LSm4Fng== + dependencies: + "@jest/types" "^24.7.0" + +jest-pnp-resolver@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.0.2.tgz#470384ae9ea31f72136db52618aa4010ff23b715" + integrity sha512-H2DvUlwdMedNGv4FOliPDnxani6ATWy70xe2eckGJgkLoMaWzRPqpSlc5ShqX0Ltk5OhRQvPQY2LLZPOpgcc7g== + +jest-regex-util@^23.3.0: + version "23.3.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-23.3.0.tgz#5f86729547c2785c4002ceaa8f849fe8ca471bc5" + integrity sha1-X4ZylUfCeFxAAs6qj4Sf6MpHG8U= + +jest-regex-util@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-24.3.0.tgz#d5a65f60be1ae3e310d5214a0307581995227b36" + integrity sha512-tXQR1NEOyGlfylyEjg1ImtScwMq8Oh3iJbGTjN7p0J23EuVX1MA8rwU69K4sLbCmwzgCUbVkm0FkSF9TdzOhtg== + +jest-resolve-dependencies@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-23.6.0.tgz#b4526af24c8540d9a3fab102c15081cf509b723d" + integrity sha512-EkQWkFWjGKwRtRyIwRwI6rtPAEyPWlUC2MpzHissYnzJeHcyCn1Hc8j7Nn1xUVrS5C6W5+ZL37XTem4D4pLZdA== + dependencies: + jest-regex-util "^23.3.0" + jest-snapshot "^23.6.0" + +jest-resolve@23.6.0, jest-resolve@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-23.6.0.tgz#cf1d1a24ce7ee7b23d661c33ba2150f3aebfa0ae" + integrity sha512-XyoRxNtO7YGpQDmtQCmZjum1MljDqUCob7XlZ6jy9gsMugHdN2hY4+Acz9Qvjz2mSsOnPSH7skBmDYCHXVZqkA== + dependencies: + browser-resolve "^1.11.3" + chalk "^2.0.1" + realpath-native "^1.0.0" + +jest-runner@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-23.6.0.tgz#3894bd219ffc3f3cb94dc48a4170a2e6f23a5a38" + integrity sha512-kw0+uj710dzSJKU6ygri851CObtCD9cN8aNkg8jWJf4ewFyEa6kwmiH/r/M1Ec5IL/6VFa0wnAk6w+gzUtjJzA== + dependencies: + exit "^0.1.2" + graceful-fs "^4.1.11" + jest-config "^23.6.0" + jest-docblock "^23.2.0" + jest-haste-map "^23.6.0" + jest-jasmine2 "^23.6.0" + jest-leak-detector "^23.6.0" + jest-message-util "^23.4.0" + jest-runtime "^23.6.0" + jest-util "^23.4.0" + jest-worker "^23.2.0" + source-map-support "^0.5.6" + throat "^4.0.0" + +jest-runtime@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-23.6.0.tgz#059e58c8ab445917cd0e0d84ac2ba68de8f23082" + integrity sha512-ycnLTNPT2Gv+TRhnAYAQ0B3SryEXhhRj1kA6hBPSeZaNQkJ7GbZsxOLUkwg6YmvWGdX3BB3PYKFLDQCAE1zNOw== + dependencies: + babel-core "^6.0.0" + babel-plugin-istanbul "^4.1.6" + chalk "^2.0.1" + convert-source-map "^1.4.0" + exit "^0.1.2" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.1.11" + jest-config "^23.6.0" + jest-haste-map "^23.6.0" + jest-message-util "^23.4.0" + jest-regex-util "^23.3.0" + jest-resolve "^23.6.0" + jest-snapshot "^23.6.0" + jest-util "^23.4.0" + jest-validate "^23.6.0" + micromatch "^2.3.11" + realpath-native "^1.0.0" + slash "^1.0.0" + strip-bom "3.0.0" + write-file-atomic "^2.1.0" + yargs "^11.0.0" + +jest-serializer@^23.0.1: + version "23.0.1" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-23.0.1.tgz#a3776aeb311e90fe83fab9e533e85102bd164165" + integrity sha1-o3dq6zEekP6D+rnlM+hRAr0WQWU= + +jest-serializer@^24.4.0: + version "24.4.0" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-24.4.0.tgz#f70c5918c8ea9235ccb1276d232e459080588db3" + integrity sha512-k//0DtglVstc1fv+GY/VHDIjrtNjdYvYjMlbLUed4kxrE92sIUewOi5Hj3vrpB8CXfkJntRPDRjCrCvUhBdL8Q== + +jest-snapshot@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-23.6.0.tgz#f9c2625d1b18acda01ec2d2b826c0ce58a5aa17a" + integrity sha512-tM7/Bprftun6Cvj2Awh/ikS7zV3pVwjRYU2qNYS51VZHgaAMBs5l4o/69AiDHhQrj5+LA2Lq4VIvK7zYk/bswg== + dependencies: + babel-types "^6.0.0" + chalk "^2.0.1" + jest-diff "^23.6.0" + jest-matcher-utils "^23.6.0" + jest-message-util "^23.4.0" + jest-resolve "^23.6.0" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + pretty-format "^23.6.0" + semver "^5.5.0" + +jest-util@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-23.4.0.tgz#4d063cb927baf0a23831ff61bec2cbbf49793561" + integrity sha1-TQY8uSe68KI4Mf9hvsLLv0l5NWE= + dependencies: + callsites "^2.0.0" + chalk "^2.0.1" + graceful-fs "^4.1.11" + is-ci "^1.0.10" + jest-message-util "^23.4.0" + mkdirp "^0.5.1" + slash "^1.0.0" + source-map "^0.6.0" + +jest-util@^24.7.1: + version "24.7.1" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-24.7.1.tgz#b4043df57b32a23be27c75a2763d8faf242038ff" + integrity sha512-/KilOue2n2rZ5AnEBYoxOXkeTu6vi7cjgQ8MXEkih0oeAXT6JkS3fr7/j8+engCjciOU1Nq5loMSKe0A1oeX0A== + dependencies: + "@jest/console" "^24.7.1" + "@jest/fake-timers" "^24.7.1" + "@jest/source-map" "^24.3.0" + "@jest/test-result" "^24.7.1" + "@jest/types" "^24.7.0" + callsites "^3.0.0" + chalk "^2.0.1" + graceful-fs "^4.1.15" + is-ci "^2.0.0" + mkdirp "^0.5.1" + slash "^2.0.0" + source-map "^0.6.0" + +jest-validate@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-23.6.0.tgz#36761f99d1ed33fcd425b4e4c5595d62b6597474" + integrity sha512-OFKapYxe72yz7agrDAWi8v2WL8GIfVqcbKRCLbRG9PAxtzF9b1SEDdTpytNDN12z2fJynoBwpMpvj2R39plI2A== + dependencies: + chalk "^2.0.1" + jest-get-type "^22.1.0" + leven "^2.1.0" + pretty-format "^23.6.0" + +jest-watch-typeahead@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/jest-watch-typeahead/-/jest-watch-typeahead-0.2.1.tgz#6c40f232996ca6c39977e929e9f79b189e7d87e4" + integrity sha512-xdhEtKSj0gmnkDQbPTIHvcMmXNUDzYpHLEJ5TFqlaI+schi2NI96xhWiZk9QoesAS7oBmKwWWsHazTrYl2ORgg== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.4.1" + jest-watcher "^23.1.0" + slash "^2.0.0" + string-length "^2.0.0" + strip-ansi "^5.0.0" + +jest-watcher@^23.1.0, jest-watcher@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-23.4.0.tgz#d2e28ce74f8dad6c6afc922b92cabef6ed05c91c" + integrity sha1-0uKM50+NrWxq/JIrksq+9u0FyRw= + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.1" + string-length "^2.0.0" + +jest-worker@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-23.2.0.tgz#faf706a8da36fae60eb26957257fa7b5d8ea02b9" + integrity sha1-+vcGqNo2+uYOsmlXJX+ntdjqArk= + dependencies: + merge-stream "^1.0.1" + +jest-worker@^24.6.0: + version "24.6.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.6.0.tgz#7f81ceae34b7cde0c9827a6980c35b7cdc0161b3" + integrity sha512-jDwgW5W9qGNvpI1tNnvajh0a5IE/PuGLFmHk6aR/BZFz8tSgGw17GsDPXAJ6p91IvYDjOw8GpFbvvZGAK+DPQQ== + dependencies: + merge-stream "^1.0.1" + supports-color "^6.1.0" + +jest@23.6.0, jest@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-23.6.0.tgz#ad5835e923ebf6e19e7a1d7529a432edfee7813d" + integrity sha512-lWzcd+HSiqeuxyhG+EnZds6iO3Y3ZEnMrfZq/OTGvF/C+Z4fPMCdhWTGSAiO2Oym9rbEXfwddHhh6jqrTF3+Lw== + dependencies: + import-local "^1.0.0" + jest-cli "^23.6.0" + +joi@^11.1.1: + version "11.4.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-11.4.0.tgz#f674897537b625e9ac3d0b7e1604c828ad913ccb" + integrity sha512-O7Uw+w/zEWgbL6OcHbyACKSj0PkQeUgmehdoXVSxt92QFCq4+1390Rwh5moI2K/OgC7D8RHRZqHZxT2husMJHA== + dependencies: + hoek "4.x.x" + isemail "3.x.x" + topo "2.x.x" + +jquery@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.4.0.tgz#8de513fa0fa4b2c7d2e48a530e26f0596936efdf" + integrity sha512-ggRCXln9zEqv6OqAGXFEcshF5dSBvCkzj6Gm2gzuR5fWawaX8t7cxKVkkygKODrDAzKdoYw3l/e3pm3vlT4IbQ== + +js-cookie@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.0.tgz#1b2c279a6eece380a12168b92485265b35b1effb" + integrity sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s= + +js-levenshtein@^1.1.3: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-tokens@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" + integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= + +js-yaml@^3.12.0, js-yaml@^3.13.0, js-yaml@^3.7.0, js-yaml@^3.9.0: + version "3.13.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsbn@^0.1.0, jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jsdom@^11.5.1: + version "11.12.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8" + integrity sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw== + dependencies: + abab "^2.0.0" + acorn "^5.5.3" + acorn-globals "^4.1.0" + array-equal "^1.0.0" + cssom ">= 0.3.2 < 0.4.0" + cssstyle "^1.0.0" + data-urls "^1.0.0" + domexception "^1.0.1" + escodegen "^1.9.1" + html-encoding-sniffer "^1.0.2" + left-pad "^1.3.0" + nwsapi "^2.0.7" + parse5 "4.0.0" + pn "^1.1.0" + request "^2.87.0" + request-promise-native "^1.0.5" + sax "^1.2.4" + symbol-tree "^3.2.2" + tough-cookie "^2.3.4" + w3c-hr-time "^1.0.1" + webidl-conversions "^4.0.2" + whatwg-encoding "^1.0.3" + whatwg-mimetype "^2.1.0" + whatwg-url "^6.4.1" + ws "^5.2.0" + xml-name-validator "^3.0.0" + +jsesc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" + integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s= + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= + +json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + +json-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" + integrity sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8= + dependencies: + jsonify "~0.0.0" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +json3@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" + integrity sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE= + +json5@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" + integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= + +json5@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" + integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + dependencies: + minimist "^1.2.0" + +json5@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" + integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== + dependencies: + minimist "^1.2.0" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +jsx-ast-utils@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz#e801b1b39985e20fffc87b40e3748080e2dcac7f" + integrity sha1-6AGxs5mF4g//yHtA43SAgOLcrH8= + dependencies: + array-includes "^3.0.3" + +just-curry-it@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/just-curry-it/-/just-curry-it-3.1.0.tgz#ab59daed308a58b847ada166edd0a2d40766fbc5" + integrity sha512-mjzgSOFzlrurlURaHVjnQodyPNvrHrf1TbQP2XU9NSqBtHQPuHZ+Eb6TAJP7ASeJN9h9K0KXoRTs8u6ouHBKvg== + +killable@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" + integrity sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg== + +kind-of@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" + integrity sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU= + dependencies: + is-buffer "^1.0.2" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== + +kleur@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-2.0.2.tgz#b704f4944d95e255d038f0cb05fb8a602c55a300" + integrity sha512-77XF9iTllATmG9lSlIv0qdQ2BQ/h9t0bJllHlbvsQ0zUWfU7Yi0S8L5JXzPZgkefIiajLmBJJ4BsMJmqcf7oxQ== + +last-call-webpack-plugin@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" + integrity sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w== + dependencies: + lodash "^4.17.5" + webpack-sources "^1.1.0" + +lazy-cache@^0.2.3: + version "0.2.7" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-0.2.7.tgz#7feddf2dcb6edb77d11ef1d117ab5ffdf0ab1b65" + integrity sha1-f+3fLctu23fRHvHRF6tf/fCrG2U= + +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= + +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= + dependencies: + invert-kv "^1.0.0" + +lcid@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" + integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== + dependencies: + invert-kv "^2.0.0" + +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + +leven@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" + integrity sha1-wuep93IJTe6dNCAq6KzORoeHVYA= + +levn@^0.3.0, levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +load-json-file@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" + integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA= + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + +load-json-file@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" + integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + strip-bom "^3.0.0" + +load-json-file@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" + integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs= + dependencies: + graceful-fs "^4.1.2" + parse-json "^4.0.0" + pify "^3.0.0" + strip-bom "^3.0.0" + +loader-fs-cache@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/loader-fs-cache/-/loader-fs-cache-1.0.2.tgz#54cedf6b727e1779fd8f01205f05f6e88706f086" + integrity sha512-70IzT/0/L+M20jUlEqZhZyArTU6VKLRTYRDAYN26g4jfzpJqjipLL3/hgYpySqI9PwsVRHHFja0LfEmsx9X2Cw== + dependencies: + find-cache-dir "^0.1.1" + mkdirp "0.5.1" + +loader-runner@^2.3.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" + integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== + +loader-utils@1.2.3, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" + integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA== + dependencies: + big.js "^5.2.2" + emojis-list "^2.0.0" + json5 "^1.0.1" + +locate-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" + integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= + dependencies: + p-locate "^2.0.0" + path-exists "^3.0.0" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +lodash._baseisequal@^3.0.0: + version "3.0.7" + resolved "https://registry.yarnpkg.com/lodash._baseisequal/-/lodash._baseisequal-3.0.7.tgz#d8025f76339d29342767dcc887ce5cb95a5b51f1" + integrity sha1-2AJfdjOdKTQnZ9zIh85cuVpbUfE= + dependencies: + lodash.isarray "^3.0.0" + lodash.istypedarray "^3.0.0" + lodash.keys "^3.0.0" + +lodash._bindcallback@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" + integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4= + +lodash._getnative@^3.0.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" + integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U= + +lodash._reinterpolate@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" + integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= + +lodash.escape@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98" + integrity sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg= + +lodash.flattendeep@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= + +lodash.isarguments@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo= + +lodash.isarray@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" + integrity sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U= + +lodash.isequal@^3.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-3.0.4.tgz#1c35eb3b6ef0cd1ff51743e3ea3cf7fdffdacb64" + integrity sha1-HDXrO27wzR/1F0Pj6jz3/f/ay2Q= + dependencies: + lodash._baseisequal "^3.0.0" + lodash._bindcallback "^3.0.0" + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + +lodash.isfunction@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051" + integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw== + +lodash.isobject@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" + integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0= + +lodash.istypedarray@^3.0.0: + version "3.0.6" + resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62" + integrity sha1-yaR3SYYHUB2OhJTSg7h8OSgc72I= + +lodash.keys@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" + integrity sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo= + dependencies: + lodash._getnative "^3.0.0" + lodash.isarguments "^3.0.0" + lodash.isarray "^3.0.0" + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= + +lodash.tail@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" + integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= + +lodash.template@^4.2.4, lodash.template@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" + integrity sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A= + dependencies: + lodash._reinterpolate "~3.0.0" + lodash.templatesettings "^4.0.0" + +lodash.templatesettings@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316" + integrity sha1-K01OlbpEDZFf8IvImeRVNmZxMxY= + dependencies: + lodash._reinterpolate "~3.0.0" + +lodash.tonumber@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/lodash.tonumber/-/lodash.tonumber-4.0.3.tgz#0b96b31b35672793eb7f5a63ee791f1b9e9025d9" + integrity sha1-C5azGzVnJ5Prf1pj7nkfG56QJdk= + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= + +"lodash@>=3.5 <5", lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== + +loglevel@^1.4.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa" + integrity sha1-4PyVEztu8nbNyIh82vJKpvFW+Po= + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw= + +lru-cache@^4.0.1: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +make-dir@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" + integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + dependencies: + tmpl "1.0.x" + +map-age-cleaner@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" + integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== + dependencies: + p-defer "^1.0.0" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +math-random@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" + integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +mdn-data@~1.1.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-1.1.4.tgz#50b5d4ffc4575276573c4eedb8780812a8419f01" + integrity sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + +mem@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" + integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y= + dependencies: + mimic-fn "^1.0.0" + +mem@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" + integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== + dependencies: + map-age-cleaner "^0.1.1" + mimic-fn "^2.0.0" + p-is-promise "^2.0.0" + +memory-fs@^0.4.0, memory-fs@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" + integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +merge-deep@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.2.tgz#f39fa100a4f1bd34ff29f7d2bf4508fbb8d83ad2" + integrity sha512-T7qC8kg4Zoti1cFd8Cr0M+qaZfOwjlPDEdZIIPPB2JZctjaPM4fX+i7HOId69tAti2fvO6X5ldfYUONDODsrkA== + dependencies: + arr-union "^3.1.0" + clone-deep "^0.2.4" + kind-of "^3.0.2" + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= + +merge-stream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1" + integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE= + dependencies: + readable-stream "^2.0.1" + +merge2@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5" + integrity sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA== + +merge@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" + integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== + +methods@^1.1.1, methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + +micromatch@^2.3.11: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU= + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8, micromatch@^3.1.9: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + 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" + +miller-rabin@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" + integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + +"mime-db@>= 1.38.0 < 2": + version "1.39.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.39.0.tgz#f95a20275742f7d2ad0429acfe40f4233543780e" + integrity sha512-DTsrw/iWVvwHH+9Otxccdyy0Tgiil6TWK/xhfARJZF/QFhwOgZgOIvA2/VIGpM8U7Q8z5nDmdDWC6tuVMJNibw== + +mime-db@~1.38.0: + version "1.38.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.38.0.tgz#1a2aab16da9eb167b49c6e4df2d9c68d63d8e2ad" + integrity sha512-bqVioMFFzc2awcdJZIzR3HjZFX20QhilVS7hytkKrv7xFAn8bM1gzc/FOX2awLISvWe0PV8ptFKcon+wZ5qYkg== + +mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.18, mime-types@~2.1.19: + version "2.1.22" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.22.tgz#fe6b355a190926ab7698c9a0556a11199b2199bd" + integrity sha512-aGl6TZGnhm/li6F7yx82bJiBZwgiEa4Hf6CNr8YO+r5UHr53tSTYZb102zyU50DOWWKeOv0uQLRL0/9EiKWCog== + dependencies: + mime-db "~1.38.0" + +mime@1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" + integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== + +mime@^1.4.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@^2.0.3, mime@^2.3.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.2.tgz#ce5229a5e99ffc313abac806b482c10e7ba6ac78" + integrity sha512-zJBfZDkwRu+j3Pdd2aHsR5GfH2jIWhmL1ZzBoc+X+3JEti2hbArWcyJ+1laC1D2/U/W1a/+Cegj0/OnEU2ybjg== + +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mimic-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +mini-css-extract-plugin@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.5.0.tgz#ac0059b02b9692515a637115b0cc9fed3a35c7b0" + integrity sha512-IuaLjruM0vMKhUUT51fQdQzBYTX49dLj8w68ALEAe2A4iYNpIC4eMac67mt3NzycvjOlf07/kYxJDc0RTl1Wqw== + dependencies: + loader-utils "^1.1.0" + schema-utils "^1.0.0" + webpack-sources "^1.1.0" + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + +minimatch@3.0.4, minimatch@^3.0.3, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= + +minimist@^1.1.1, minimist@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= + +minimist@~0.0.1: + version "0.0.10" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" + integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= + +minipass@^2.2.1, minipass@^2.3.4: + version "2.3.5" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" + integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== + dependencies: + safe-buffer "^5.1.2" + yallist "^3.0.0" + +minizlib@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" + integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA== + dependencies: + minipass "^2.2.1" + +mississippi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" + integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA== + dependencies: + concat-stream "^1.5.0" + duplexify "^3.4.2" + end-of-stream "^1.1.0" + flush-write-stream "^1.0.0" + from2 "^2.1.0" + parallel-transform "^1.1.0" + pump "^3.0.0" + pumpify "^1.3.3" + stream-each "^1.1.0" + through2 "^2.0.0" + +mixin-deep@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mixin-object@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" + integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4= + dependencies: + for-in "^0.1.3" + is-extendable "^0.1.1" + +mkdirp@0.5.1, mkdirp@0.5.x, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= + dependencies: + minimist "0.0.8" + +moo@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.4.3.tgz#3f847a26f31cf625a956a87f2b10fbc013bfd10e" + integrity sha512-gFD2xGCl8YFgGHsqJ9NKRVdwlioeW3mI1iqfLNYQOv0+6JRwG58Zk9DIGQgyIaffSYaO1xsKnMaYzzNr1KyIAw== + +move-concurrently@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" + integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= + dependencies: + aproba "^1.1.1" + copy-concurrently "^1.0.0" + fs-write-stream-atomic "^1.0.8" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.3" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== + +multicast-dns-service-types@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" + integrity sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE= + +multicast-dns@^6.0.1: + version "6.2.3" + resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" + integrity sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== + dependencies: + dns-packet "^1.3.1" + thunky "^1.0.2" + +mute-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" + integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= + +nan@^2.9.2: + version "2.13.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7" + integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + 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@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + +nearley@^2.7.10: + version "2.16.0" + resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.16.0.tgz#77c297d041941d268290ec84b739d0ee297e83a7" + integrity sha512-Tr9XD3Vt/EujXbZBv6UAHYoLUSMQAxSsTnm9K3koXzjzNWY195NqALeyrzLZBKzAkL3gl92BcSogqrHjD8QuUg== + dependencies: + commander "^2.19.0" + moo "^0.4.3" + railroad-diagrams "^1.0.0" + randexp "0.4.6" + semver "^5.4.1" + +needle@^2.2.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.0.tgz#ce3fea21197267bacb310705a7bbe24f2a3a3492" + integrity sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg== + dependencies: + debug "^4.1.0" + iconv-lite "^0.4.4" + sax "^1.2.4" + +negotiator@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" + integrity sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk= + +neo-async@^2.5.0, neo-async@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.0.tgz#b9d15e4d71c6762908654b5183ed38b753340835" + integrity sha512-MFh0d/Wa7vkKO3Y3LlacqAEeHK0mckVqzDieUKTT+KGxi+zIpeVsFxymkIiRpbpDziHc290Xr9A1O4Om7otoRA== + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +no-case@^2.2.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" + integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ== + dependencies: + lower-case "^1.1.1" + +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-forge@0.7.5: + version "0.7.5" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df" + integrity sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ== + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + +node-libs-browser@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.0.tgz#c72f60d9d46de08a940dedbb25f3ffa2f9bbaa77" + integrity sha512-5MQunG/oyOaBdttrL40dA7bUfPORLRWMUJLQtMg7nluxUvk5XwnLdL9twQHFAjRx/y7mIMkLKT9++qPbbk6BZA== + dependencies: + assert "^1.1.1" + browserify-zlib "^0.2.0" + buffer "^4.3.0" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + crypto-browserify "^3.11.0" + domain-browser "^1.1.1" + events "^3.0.0" + https-browserify "^1.0.0" + os-browserify "^0.3.0" + path-browserify "0.0.0" + process "^0.11.10" + punycode "^1.2.4" + querystring-es3 "^0.2.0" + readable-stream "^2.3.3" + stream-browserify "^2.0.1" + stream-http "^2.7.2" + string_decoder "^1.0.0" + timers-browserify "^2.0.4" + tty-browserify "0.0.0" + url "^0.11.0" + util "^0.11.0" + vm-browserify "0.0.4" + +node-notifier@^5.2.1: + version "5.4.0" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.4.0.tgz#7b455fdce9f7de0c63538297354f3db468426e6a" + integrity sha512-SUDEb+o71XR5lXSTyivXd9J7fCloE3SyP4lSgt3lU2oSANiox+SxlNRGPjDKrwU1YN3ix2KN/VGGCg0t01rttQ== + dependencies: + growly "^1.3.0" + is-wsl "^1.1.0" + semver "^5.5.0" + shellwords "^0.1.1" + which "^1.3.0" + +node-pre-gyp@^0.10.0: + version "0.10.3" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" + integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.1" + needle "^2.2.1" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4" + +node-releases@^1.1.13, node-releases@^1.1.3: + version "1.1.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.14.tgz#f1f41c83cac82caebd6739e6313d56b3b09c9189" + integrity sha512-d58EpVZRhQE60kWiWUaaPlK9dyC4zg3ZoMcHcky2d4hDksyQj0rUozwInOl0C66mBsqo01Tuns8AvxnL5S7PKg== + dependencies: + semver "^5.3.0" + +nopt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" + integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= + dependencies: + abbrev "1" + osenv "^0.1.4" + +normalize-package-data@^2.3.2: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.0.1, normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= + +normalize-url@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" + integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== + +normalizr@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/normalizr/-/normalizr-3.3.0.tgz#6f44b95e8bf2201845a9e551920c4e5861166d27" + integrity sha512-u8Us8Ms5KpY0mmNwML4OBxNKvlmSKeXfPBIXU9XmcejrZUjhIvUOd8RYBq62UL4JHxrcO4wqo2sL4s8B74Hadw== + +npm-bundled@^1.0.1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" + integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g== + +npm-packlist@^1.1.6: + version "1.4.1" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc" + integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw== + dependencies: + ignore-walk "^3.0.1" + npm-bundled "^1.0.1" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +npmlog@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +nth-check@^1.0.2, nth-check@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + +num2fraction@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" + integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +nwsapi@^2.0.7: + version "2.1.3" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.1.3.tgz#25f3a5cec26c654f7376df6659cdf84b99df9558" + integrity sha512-RowAaJGEgYXEZfQ7tvvdtAQUKPyTR6T6wNu0fwlNsGQYr/h3yQc6oI8WnVZh3Y/Sylwc+dtAlvPqfFZjhTyk3A== + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +object-assign@4.1.1, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-hash@^1.1.4: + version "1.3.1" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-1.3.1.tgz#fde452098a951cb145f039bb7d455449ddc126df" + integrity sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA== + +object-inspect@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.6.0.tgz#c70b6cbf72f274aab4c34c0c82f5167bf82cf15b" + integrity sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ== + +object-is@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.1.tgz#0aa60ec9989a0b3ed795cf4d06f62cf1ad6539b6" + integrity sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY= + +object-keys@^1.0.11, object-keys@^1.0.12: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.assign@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +object.entries@^1.0.4, object.entries@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.0.tgz#2024fc6d6ba246aee38bdb0ffd5cfbcf371b7519" + integrity sha512-l+H6EQ8qzGRxbkHOd5I/aHRhHDKoQXQ8g0BYt4uSweQU1/J6dZUOyWh9a2Vky35YCKjzmgxOzta2hH6kf9HuXA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.12.0" + function-bind "^1.1.1" + has "^1.0.3" + +object.fromentries@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.0.tgz#49a543d92151f8277b3ac9600f1e930b189d30ab" + integrity sha512-9iLiI6H083uiqUuvzyY6qrlmc/Gz8hLQFOcb/Ri/0xXFkSNS3ctV+CbE6yM2+AnkYfOB3dGjdzC0wrMLIhQICA== + dependencies: + define-properties "^1.1.2" + es-abstract "^1.11.0" + function-bind "^1.1.1" + has "^1.0.1" + +object.getownpropertydescriptors@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" + integrity sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.1" + +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo= + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +object.values@^1.0.4, object.values@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.0.tgz#bf6810ef5da3e5325790eaaa2be213ea84624da9" + integrity sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.12.0" + function-bind "^1.1.1" + has "^1.0.3" + +obuf@^1.0.0, obuf@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + dependencies: + ee-first "1.1.1" + +on-headers@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= + dependencies: + mimic-fn "^1.0.0" + +opn@5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/opn/-/opn-5.4.0.tgz#cb545e7aab78562beb11aa3bfabc7042e1761035" + integrity sha512-YF9MNdVy/0qvJvDtunAOzFw9iasOQHpVthTCvGzxt61Il64AYSGdK+rYwld7NAfk9qJ7dt+hymBNSc9LNYS+Sw== + dependencies: + is-wsl "^1.1.0" + +opn@^5.1.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" + integrity sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA== + dependencies: + is-wsl "^1.1.0" + +optimist@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" + integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY= + dependencies: + minimist "~0.0.1" + wordwrap "~0.0.2" + +optimize-css-assets-webpack-plugin@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-5.0.1.tgz#9eb500711d35165b45e7fd60ba2df40cb3eb9159" + integrity sha512-Rqm6sSjWtx9FchdP0uzTQDc7GXDKnwVEGoSxjezPkzMewx7gEWE9IMUYKmigTRC4U3RaNSwYVnUDLuIdtTpm0A== + dependencies: + cssnano "^4.1.0" + last-call-webpack-plugin "^3.0.0" + +optionator@^0.8.1, optionator@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q= + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +original@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" + integrity sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg== + dependencies: + url-parse "^1.4.3" + +os-browserify@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" + integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= + +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + +os-locale@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" + integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA== + dependencies: + execa "^0.7.0" + lcid "^1.0.0" + mem "^1.1.0" + +os-locale@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" + integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== + dependencies: + execa "^1.0.0" + lcid "^2.0.0" + mem "^4.0.0" + +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +osenv@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" + integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-is-promise@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" + integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== + +p-limit@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" + integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== + dependencies: + p-try "^1.0.0" + +p-limit@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.0.tgz#417c9941e6027a9abcba5092dd2904e255b5fbc2" + integrity sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ== + dependencies: + p-try "^2.0.0" + +p-locate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" + integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= + dependencies: + p-limit "^1.1.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-map@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" + integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== + +p-try@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" + integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +pako@~1.0.5: + version "1.0.10" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.10.tgz#4328badb5086a426aa90f541977d4955da5c9732" + integrity sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw== + +parallel-transform@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" + integrity sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY= + dependencies: + cyclist "~0.2.2" + inherits "^2.0.3" + readable-stream "^2.1.5" + +param-case@2.1.x: + version "2.1.1" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" + integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc= + dependencies: + no-case "^2.2.0" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-asn1@^5.0.0: + version "5.1.4" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.4.tgz#37f6628f823fbdeb2273b4d540434a22f3ef1fcc" + integrity sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw== + dependencies: + asn1.js "^4.0.0" + browserify-aes "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.0" + pbkdf2 "^3.0.3" + safe-buffer "^5.1.1" + +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw= + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + +parse-json@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" + integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= + dependencies: + error-ex "^1.2.0" + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse5@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" + integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== + +parse5@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c" + integrity sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA== + dependencies: + "@types/node" "*" + +parse5@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" + integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ== + +parseurl@~1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" + integrity sha1-/CidTtiZMRlGDBViUyYs3I3mW/M= + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +path-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" + integrity sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo= + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= + +path-exists@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" + integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s= + dependencies: + pinkie-promise "^2.0.0" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-is-inside@^1.0.1, path-is-inside@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +path-parse@^1.0.5, path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= + +path-to-regexp@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" + integrity sha1-Wf3g9DW62suhA6hOnTvGTpa5k30= + dependencies: + isarray "0.0.1" + +path-type@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" + integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE= + dependencies: + graceful-fs "^4.1.2" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +path-type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" + integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= + dependencies: + pify "^2.0.0" + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== + dependencies: + pify "^3.0.0" + +pbkdf2@^3.0.3: + version "3.0.17" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6" + integrity sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA== + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +pify@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + +pkg-dir@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-1.0.0.tgz#7a4b508a8d5bb2d629d447056ff4e9c9314cf3d4" + integrity sha1-ektQio1bstYp1EcFb/TpyTFM89Q= + dependencies: + find-up "^1.0.0" + +pkg-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" + integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= + dependencies: + find-up "^2.1.0" + +pkg-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" + integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== + dependencies: + find-up "^3.0.0" + +pkg-up@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f" + integrity sha1-yBmscoBZpGHKscOImivjxJoATX8= + dependencies: + find-up "^2.1.0" + +pluralize@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" + integrity sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow== + +pn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" + integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== + +pnp-webpack-plugin@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.2.1.tgz#cd9d698df2a6fcf7255093c1c9511adf65b9421b" + integrity sha512-W6GctK7K2qQiVR+gYSv/Gyt6jwwIH4vwdviFqx+Y2jAtVf5eZyYIDf5Ac2NCDMBiX5yWscBLZElPTsyA1UtVVA== + dependencies: + ts-pnp "^1.0.0" + +popper.js@^1.14.1, popper.js@^1.14.4: + version "1.15.0" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" + integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== + +portfinder@^1.0.9: + version "1.0.20" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.20.tgz#bea68632e54b2e13ab7b0c4775e9b41bf270e44a" + integrity sha512-Yxe4mTyDzTd59PZJY4ojZR8F+E5e97iq2ZOHPz3HDgSvYC5siNad2tLooQ5y5QHyQhc3xVqvyk/eNA3wuoa7Sw== + dependencies: + async "^1.5.2" + debug "^2.2.0" + mkdirp "0.5.x" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +postcss-attribute-case-insensitive@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-4.0.1.tgz#b2a721a0d279c2f9103a36331c88981526428cc7" + integrity sha512-L2YKB3vF4PetdTIthQVeT+7YiSzMoNMLLYxPXXppOOP7NoazEAy45sh2LvJ8leCQjfBcfkYQs8TtCcQjeZTp8A== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0" + +postcss-calc@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.1.tgz#36d77bab023b0ecbb9789d84dcb23c4941145436" + integrity sha512-oXqx0m6tb4N3JGdmeMSc/i91KppbYsFZKdH0xMOqK8V1rJlzrKlTdokz8ozUXLVejydRN6u2IddxpcijRj2FqQ== + dependencies: + css-unit-converter "^1.1.1" + postcss "^7.0.5" + postcss-selector-parser "^5.0.0-rc.4" + postcss-value-parser "^3.3.1" + +postcss-color-functional-notation@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-color-functional-notation/-/postcss-color-functional-notation-2.0.1.tgz#5efd37a88fbabeb00a2966d1e53d98ced93f74e0" + integrity sha512-ZBARCypjEDofW4P6IdPVTLhDNXPRn8T2s1zHbZidW6rPaaZvcnCS2soYFIQJrMZSxiePJ2XIYTlcb2ztr/eT2g== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-color-gray@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-color-gray/-/postcss-color-gray-5.0.0.tgz#532a31eb909f8da898ceffe296fdc1f864be8547" + integrity sha512-q6BuRnAGKM/ZRpfDascZlIZPjvwsRye7UDNalqVz3s7GDxMtqPY6+Q871liNxsonUw8oC61OG+PSaysYpl1bnw== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.5" + postcss-values-parser "^2.0.0" + +postcss-color-hex-alpha@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/postcss-color-hex-alpha/-/postcss-color-hex-alpha-5.0.3.tgz#a8d9ca4c39d497c9661e374b9c51899ef0f87388" + integrity sha512-PF4GDel8q3kkreVXKLAGNpHKilXsZ6xuu+mOQMHWHLPNyjiUBOr75sp5ZKJfmv1MCus5/DWUGcK9hm6qHEnXYw== + dependencies: + postcss "^7.0.14" + postcss-values-parser "^2.0.1" + +postcss-color-mod-function@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/postcss-color-mod-function/-/postcss-color-mod-function-3.0.3.tgz#816ba145ac11cc3cb6baa905a75a49f903e4d31d" + integrity sha512-YP4VG+xufxaVtzV6ZmhEtc+/aTXH3d0JLpnYfxqTvwZPbJhWqp8bSY3nfNzNRFLgB4XSaBA82OE4VjOOKpCdVQ== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-color-rebeccapurple@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-4.0.1.tgz#c7a89be872bb74e45b1e3022bfe5748823e6de77" + integrity sha512-aAe3OhkS6qJXBbqzvZth2Au4V3KieR5sRQ4ptb2b2O8wgvB3SJBsdG+jsn2BZbbwekDG8nTfcCNKcSfe/lEy8g== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-colormin@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" + integrity sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== + dependencies: + browserslist "^4.0.0" + color "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-convert-values@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" + integrity sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-custom-media@^7.0.7: + version "7.0.8" + resolved "https://registry.yarnpkg.com/postcss-custom-media/-/postcss-custom-media-7.0.8.tgz#fffd13ffeffad73621be5f387076a28b00294e0c" + integrity sha512-c9s5iX0Ge15o00HKbuRuTqNndsJUbaXdiNsksnVH8H4gdc+zbLzr/UasOwNG6CTDpLFekVY4672eWdiiWu2GUg== + dependencies: + postcss "^7.0.14" + +postcss-custom-properties@^8.0.9: + version "8.0.10" + resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-8.0.10.tgz#e8dc969e1e15c555f0b836b7f278ef47e3cdeaff" + integrity sha512-GDL0dyd7++goDR4SSasYdRNNvp4Gqy1XMzcCnTijiph7VB27XXpJ8bW/AI0i2VSBZ55TpdGhMr37kMSpRfYD0Q== + dependencies: + postcss "^7.0.14" + postcss-values-parser "^2.0.1" + +postcss-custom-selectors@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-custom-selectors/-/postcss-custom-selectors-5.1.2.tgz#64858c6eb2ecff2fb41d0b28c9dd7b3db4de7fba" + integrity sha512-DSGDhqinCqXqlS4R7KGxL1OSycd1lydugJ1ky4iRXPHdBRiozyMHrdu0H3o7qNOCiZwySZTUI5MV0T8QhCLu+w== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-dir-pseudo-class@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-5.0.0.tgz#6e3a4177d0edb3abcc85fdb6fbb1c26dabaeaba2" + integrity sha512-3pm4oq8HYWMZePJY+5ANriPs3P07q+LW6FAdTlkFH2XqDdP4HeeJYMOzn0HYLhRSjBO3fhiqSwwU9xEULSrPgw== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-discard-comments@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" + integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== + dependencies: + postcss "^7.0.0" + +postcss-discard-duplicates@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" + integrity sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== + dependencies: + postcss "^7.0.0" + +postcss-discard-empty@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" + integrity sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== + dependencies: + postcss "^7.0.0" + +postcss-discard-overridden@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" + integrity sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== + dependencies: + postcss "^7.0.0" + +postcss-double-position-gradients@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postcss-double-position-gradients/-/postcss-double-position-gradients-1.0.0.tgz#fc927d52fddc896cb3a2812ebc5df147e110522e" + integrity sha512-G+nV8EnQq25fOI8CH/B6krEohGWnF5+3A6H/+JEpOncu5dCnkS1QQ6+ct3Jkaepw1NGVqqOZH6lqrm244mCftA== + dependencies: + postcss "^7.0.5" + postcss-values-parser "^2.0.0" + +postcss-env-function@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-env-function/-/postcss-env-function-2.0.2.tgz#0f3e3d3c57f094a92c2baf4b6241f0b0da5365d7" + integrity sha512-rwac4BuZlITeUbiBq60h/xbLzXY43qOsIErngWa4l7Mt+RaSkT7QBjXVGTcBHupykkblHMDrBFh30zchYPaOUw== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-flexbugs-fixes@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-4.1.0.tgz#e094a9df1783e2200b7b19f875dcad3b3aff8b20" + integrity sha512-jr1LHxQvStNNAHlgco6PzY308zvLklh7SJVYuWUwyUQncofaAlD2l+P/gxKHOdqWKe7xJSkVLFF/2Tp+JqMSZA== + dependencies: + postcss "^7.0.0" + +postcss-focus-visible@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-focus-visible/-/postcss-focus-visible-4.0.0.tgz#477d107113ade6024b14128317ade2bd1e17046e" + integrity sha512-Z5CkWBw0+idJHSV6+Bgf2peDOFf/x4o+vX/pwcNYrWpXFrSfTkQ3JQ1ojrq9yS+upnAlNRHeg8uEwFTgorjI8g== + dependencies: + postcss "^7.0.2" + +postcss-focus-within@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-focus-within/-/postcss-focus-within-3.0.0.tgz#763b8788596cee9b874c999201cdde80659ef680" + integrity sha512-W0APui8jQeBKbCGZudW37EeMCjDeVxKgiYfIIEo8Bdh5SpB9sxds/Iq8SEuzS0Q4YFOlG7EPFulbbxujpkrV2w== + dependencies: + postcss "^7.0.2" + +postcss-font-variant@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-font-variant/-/postcss-font-variant-4.0.0.tgz#71dd3c6c10a0d846c5eda07803439617bbbabacc" + integrity sha512-M8BFYKOvCrI2aITzDad7kWuXXTm0YhGdP9Q8HanmN4EF1Hmcgs1KK5rSHylt/lUJe8yLxiSwWAHdScoEiIxztg== + dependencies: + postcss "^7.0.2" + +postcss-gap-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-gap-properties/-/postcss-gap-properties-2.0.0.tgz#431c192ab3ed96a3c3d09f2ff615960f902c1715" + integrity sha512-QZSqDaMgXCHuHTEzMsS2KfVDOq7ZFiknSpkrPJY6jmxbugUPTuSzs/vuE5I3zv0WAS+3vhrlqhijiprnuQfzmg== + dependencies: + postcss "^7.0.2" + +postcss-image-set-function@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/postcss-image-set-function/-/postcss-image-set-function-3.0.1.tgz#28920a2f29945bed4c3198d7df6496d410d3f288" + integrity sha512-oPTcFFip5LZy8Y/whto91L9xdRHCWEMs3e1MdJxhgt4jy2WYXfhkng59fH5qLXSCPN8k4n94p1Czrfe5IOkKUw== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-initial@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-initial/-/postcss-initial-3.0.0.tgz#1772512faf11421b791fb2ca6879df5f68aa0517" + integrity sha512-WzrqZ5nG9R9fUtrA+we92R4jhVvEB32IIRTzfIG/PLL8UV4CvbF1ugTEHEFX6vWxl41Xt5RTCJPEZkuWzrOM+Q== + dependencies: + lodash.template "^4.2.4" + postcss "^7.0.2" + +postcss-lab-function@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-lab-function/-/postcss-lab-function-2.0.1.tgz#bb51a6856cd12289ab4ae20db1e3821ef13d7d2e" + integrity sha512-whLy1IeZKY+3fYdqQFuDBf8Auw+qFuVnChWjmxm/UhHWqNHZx+B99EwxTvGYmUBqe3Fjxs4L1BoZTJmPu6usVg== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-load-config@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.0.0.tgz#f1312ddbf5912cd747177083c5ef7a19d62ee484" + integrity sha512-V5JBLzw406BB8UIfsAWSK2KSwIJ5yoEIVFb4gVkXci0QdKgA24jLmHZ/ghe/GgX0lJ0/D1uUK1ejhzEY94MChQ== + dependencies: + cosmiconfig "^4.0.0" + import-cwd "^2.0.0" + +postcss-loader@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" + integrity sha512-cLWoDEY5OwHcAjDnkyRQzAXfs2jrKjXpO/HQFcc5b5u/r7aa471wdmChmwfnv7x2u840iat/wi0lQ5nbRgSkUA== + dependencies: + loader-utils "^1.1.0" + postcss "^7.0.0" + postcss-load-config "^2.0.0" + schema-utils "^1.0.0" + +postcss-logical@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-logical/-/postcss-logical-3.0.0.tgz#2495d0f8b82e9f262725f75f9401b34e7b45d5b5" + integrity sha512-1SUKdJc2vuMOmeItqGuNaC+N8MzBWFWEkAnRnLpFYj1tGGa7NqyVBujfRtgNa2gXR+6RkGUiB2O5Vmh7E2RmiA== + dependencies: + postcss "^7.0.2" + +postcss-media-minmax@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-media-minmax/-/postcss-media-minmax-4.0.0.tgz#b75bb6cbc217c8ac49433e12f22048814a4f5ed5" + integrity sha512-fo9moya6qyxsjbFAYl97qKO9gyre3qvbMnkOZeZwlsW6XYFsvs2DMGDlchVLfAd8LHPZDxivu/+qW2SMQeTHBw== + dependencies: + postcss "^7.0.2" + +postcss-merge-longhand@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" + integrity sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== + dependencies: + css-color-names "0.0.4" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + stylehacks "^4.0.0" + +postcss-merge-rules@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" + integrity sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + cssnano-util-same-parent "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + vendors "^1.0.0" + +postcss-minify-font-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" + integrity sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-gradients@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" + integrity sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + is-color-stop "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-params@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" + integrity sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== + dependencies: + alphanum-sort "^1.0.0" + browserslist "^4.0.0" + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + uniqs "^2.0.0" + +postcss-minify-selectors@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" + integrity sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== + dependencies: + alphanum-sort "^1.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +postcss-modules-extract-imports@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.1.tgz#dc87e34148ec7eab5f791f7cd5849833375b741a" + integrity sha512-6jt9XZwUhwmRUhb/CkyJY020PYaPJsCyt3UjbaWo6XEbH/94Hmv6MP7fG2C5NDU/BcHzyGYxNtHvM+LTf9HrYw== + dependencies: + postcss "^6.0.1" + +postcss-modules-local-by-default@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" + integrity sha1-99gMOYxaOT+nlkRmvRlQCn1hwGk= + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-scope@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" + integrity sha1-1upkmUx5+XtipytCb75gVqGUu5A= + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-values@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" + integrity sha1-7P+p1+GSUYOJ9CrQ6D9yrsRW6iA= + dependencies: + icss-replace-symbols "^1.1.0" + postcss "^6.0.1" + +postcss-nesting@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-7.0.0.tgz#6e26a770a0c8fcba33782a6b6f350845e1a448f6" + integrity sha512-WSsbVd5Ampi3Y0nk/SKr5+K34n52PqMqEfswu6RtU4r7wA8vSD+gM8/D9qq4aJkHImwn1+9iEFTbjoWsQeqtaQ== + dependencies: + postcss "^7.0.2" + +postcss-normalize-charset@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" + integrity sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== + dependencies: + postcss "^7.0.0" + +postcss-normalize-display-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" + integrity sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-positions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" + integrity sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== + dependencies: + cssnano-util-get-arguments "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-repeat-style@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" + integrity sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-string@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" + integrity sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== + dependencies: + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-timing-functions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" + integrity sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-unicode@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" + integrity sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-url@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" + integrity sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== + dependencies: + is-absolute-url "^2.0.0" + normalize-url "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-whitespace@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" + integrity sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-ordered-values@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" + integrity sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== + dependencies: + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-overflow-shorthand@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-overflow-shorthand/-/postcss-overflow-shorthand-2.0.0.tgz#31ecf350e9c6f6ddc250a78f0c3e111f32dd4c30" + integrity sha512-aK0fHc9CBNx8jbzMYhshZcEv8LtYnBIRYQD5i7w/K/wS9c2+0NSR6B3OVMu5y0hBHYLcMGjfU+dmWYNKH0I85g== + dependencies: + postcss "^7.0.2" + +postcss-page-break@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-page-break/-/postcss-page-break-2.0.0.tgz#add52d0e0a528cabe6afee8b46e2abb277df46bf" + integrity sha512-tkpTSrLpfLfD9HvgOlJuigLuk39wVTbbd8RKcy8/ugV2bNBUW3xU+AIqyxhDrQr1VUj1RmyJrBn1YWrqUm9zAQ== + dependencies: + postcss "^7.0.2" + +postcss-place@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-place/-/postcss-place-4.0.1.tgz#e9f39d33d2dc584e46ee1db45adb77ca9d1dcc62" + integrity sha512-Zb6byCSLkgRKLODj/5mQugyuj9bvAAw9LqJJjgwz5cYryGeXfFZfSXoP1UfveccFmeq0b/2xxwcTEVScnqGxBg== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-preset-env@6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-6.5.0.tgz#a14b8f6e748b2a3a4a02a56f36c390f30073b9e1" + integrity sha512-RdsIrYJd9p9AouQoJ8dFP5ksBJEIegA4q4WzJDih8nevz3cZyIP/q1Eaw3pTVpUAu3n7Y32YmvAW3X07mSRGkw== + dependencies: + autoprefixer "^9.4.2" + browserslist "^4.3.5" + caniuse-lite "^1.0.30000918" + css-blank-pseudo "^0.1.4" + css-has-pseudo "^0.10.0" + css-prefers-color-scheme "^3.1.1" + cssdb "^4.3.0" + postcss "^7.0.6" + postcss-attribute-case-insensitive "^4.0.0" + postcss-color-functional-notation "^2.0.1" + postcss-color-gray "^5.0.0" + postcss-color-hex-alpha "^5.0.2" + postcss-color-mod-function "^3.0.3" + postcss-color-rebeccapurple "^4.0.1" + postcss-custom-media "^7.0.7" + postcss-custom-properties "^8.0.9" + postcss-custom-selectors "^5.1.2" + postcss-dir-pseudo-class "^5.0.0" + postcss-double-position-gradients "^1.0.0" + postcss-env-function "^2.0.2" + postcss-focus-visible "^4.0.0" + postcss-focus-within "^3.0.0" + postcss-font-variant "^4.0.0" + postcss-gap-properties "^2.0.0" + postcss-image-set-function "^3.0.1" + postcss-initial "^3.0.0" + postcss-lab-function "^2.0.1" + postcss-logical "^3.0.0" + postcss-media-minmax "^4.0.0" + postcss-nesting "^7.0.0" + postcss-overflow-shorthand "^2.0.0" + postcss-page-break "^2.0.0" + postcss-place "^4.0.1" + postcss-pseudo-class-any-link "^6.0.0" + postcss-replace-overflow-wrap "^3.0.0" + postcss-selector-matches "^4.0.0" + postcss-selector-not "^4.0.0" + +postcss-pseudo-class-any-link@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-6.0.0.tgz#2ed3eed393b3702879dec4a87032b210daeb04d1" + integrity sha512-lgXW9sYJdLqtmw23otOzrtbDXofUdfYzNm4PIpNE322/swES3VU9XlXHeJS46zT2onFO7V1QFdD4Q9LiZj8mew== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-reduce-initial@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" + integrity sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + +postcss-reduce-transforms@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" + integrity sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== + dependencies: + cssnano-util-get-match "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-replace-overflow-wrap@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-3.0.0.tgz#61b360ffdaedca84c7c918d2b0f0d0ea559ab01c" + integrity sha512-2T5hcEHArDT6X9+9dVSPQdo7QHzG4XKclFT8rU5TzJPDN7RIRTbO9c4drUISOVemLj03aezStHCR2AIcr8XLpw== + dependencies: + postcss "^7.0.2" + +postcss-safe-parser@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-4.0.1.tgz#8756d9e4c36fdce2c72b091bbc8ca176ab1fcdea" + integrity sha512-xZsFA3uX8MO3yAda03QrG3/Eg1LN3EPfjjf07vke/46HERLZyHrTsQ9E1r1w1W//fWEhtYNndo2hQplN2cVpCQ== + dependencies: + postcss "^7.0.0" + +postcss-selector-matches@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-matches/-/postcss-selector-matches-4.0.0.tgz#71c8248f917ba2cc93037c9637ee09c64436fcff" + integrity sha512-LgsHwQR/EsRYSqlwdGzeaPKVT0Ml7LAT6E75T8W8xLJY62CE4S/l03BWIt3jT8Taq22kXP08s2SfTSzaraoPww== + dependencies: + balanced-match "^1.0.0" + postcss "^7.0.2" + +postcss-selector-not@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-4.0.0.tgz#c68ff7ba96527499e832724a2674d65603b645c0" + integrity sha512-W+bkBZRhqJaYN8XAnbbZPLWMvZD1wKTu0UxtFKdhtGjWYmxhkUneoeOhRJKdAE5V7ZTlnbHfCR+6bNwK9e1dTQ== + dependencies: + balanced-match "^1.0.0" + postcss "^7.0.2" + +postcss-selector-parser@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865" + integrity sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU= + dependencies: + dot-prop "^4.1.1" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^5.0.0, postcss-selector-parser@^5.0.0-rc.3, postcss-selector-parser@^5.0.0-rc.4: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz#249044356697b33b64f1a8f7c80922dddee7195c" + integrity sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ== + dependencies: + cssesc "^2.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-svgo@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258" + integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== + dependencies: + is-svg "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + svgo "^1.0.0" + +postcss-unique-selectors@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" + integrity sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== + dependencies: + alphanum-sort "^1.0.0" + postcss "^7.0.0" + uniqs "^2.0.0" + +postcss-value-parser@^3.0.0, postcss-value-parser@^3.3.0, postcss-value-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== + +postcss-values-parser@^2.0.0, postcss-values-parser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-values-parser/-/postcss-values-parser-2.0.1.tgz#da8b472d901da1e205b47bdc98637b9e9e550e5f" + integrity sha512-2tLuBsA6P4rYTNKCXYG/71C7j1pU6pK503suYOmn4xYrQIzW+opD+7FAFNuGSdZC/3Qfy334QbeMu7MEb8gOxg== + dependencies: + flatten "^1.0.2" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss@^6.0.1, postcss@^6.0.23: + version "6.0.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" + integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag== + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.5, postcss@^7.0.6: + version "7.0.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.14.tgz#4527ed6b1ca0d82c53ce5ec1a2041c2346bbd6e5" + integrity sha512-NsbD6XUUMZvBxtQAJuWDJeeC4QFsmWsfozWxCJPWf3M55K9iu2iMDaKqyoOdTJ1R4usBXuxlVFAIo8rZPQD4Bg== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks= + +pretty-bytes@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9" + integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk= + +pretty-error@^2.0.2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" + integrity sha1-X0+HyPkeWuPzuoerTPXgOxoX8aM= + dependencies: + renderkid "^2.0.1" + utila "~0.4" + +pretty-format@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-23.6.0.tgz#5eaac8eeb6b33b987b7fe6097ea6a8a146ab5760" + integrity sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw== + dependencies: + ansi-regex "^3.0.0" + ansi-styles "^3.2.0" + +private@^0.1.6, private@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" + integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== + +process-nextick-args@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" + integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= + +progress@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= + +promise@8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/promise/-/promise-8.0.2.tgz#9dcd0672192c589477d56891271bdc27547ae9f0" + integrity sha512-EIyzM39FpVOMbqgzEHhxdrEhtOSDOtjMZQ0M6iVfCE+kWNgCkAyOdnuCWqfmflylftfadU6FkiMgHZA2kUzwRw== + dependencies: + asap "~2.0.6" + +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + +prompts@^0.1.9: + version "0.1.14" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-0.1.14.tgz#a8e15c612c5c9ec8f8111847df3337c9cbd443b2" + integrity sha512-rxkyiE9YH6zAz/rZpywySLKkpaj0NMVyNw1qhsubdbjjSgcayjTShDreZGlFMcGSu5sab3bAKPfFk78PB90+8w== + dependencies: + kleur "^2.0.1" + sisteransi "^0.1.1" + +prop-types-exact@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869" + integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA== + dependencies: + has "^1.0.3" + object.assign "^4.1.0" + reflect.ownkeys "^0.2.0" + +prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: + version "15.7.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" + integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + +property-information@^5.0.0, property-information@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.0.1.tgz#c3b09f4f5750b1634c0b24205adbf78f18bdf94f" + integrity sha512-nAtBDVeSwFM3Ot/YxT7s4NqZmqXI7lLzf46BThvotEtYf2uk2yH0ACYuWQkJ7gxKs49PPtKVY0UlDGkyN9aJlw== + dependencies: + xtend "^4.0.1" + +proxy-addr@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.4.tgz#ecfc733bf22ff8c6f407fa275327b9ab67e48b93" + integrity sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA== + dependencies: + forwarded "~0.1.2" + ipaddr.js "1.8.0" + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + +psl@^1.1.24, psl@^1.1.28: + version "1.1.31" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" + integrity sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw== + +public-encrypt@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" + integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + safe-buffer "^5.1.2" + +pump@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^1.3.3: + version "1.5.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" + integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== + dependencies: + duplexify "^3.6.0" + inherits "^2.0.3" + pump "^2.0.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + +punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +punycode@^1.2.4, punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +q@^1.1.2: + version "1.5.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" + integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= + +qs@6.5.2, qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +qs@^6.4.0, qs@^6.5.1: + version "6.7.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" + integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== + +querystring-es3@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + +querystringify@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" + integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== + +raf@3.4.1, raf@^3.4.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + +railroad-diagrams@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" + integrity sha1-635iZ1SN3t+4mcG5Dlc3RVnN234= + +randexp@0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" + integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ== + dependencies: + discontinuous-range "1.0.0" + ret "~0.1.10" + +randomatic@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" + integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw== + dependencies: + is-number "^4.0.0" + kind-of "^6.0.0" + math-random "^1.0.1" + +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +randomfill@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" + integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== + dependencies: + randombytes "^2.0.5" + safe-buffer "^5.1.0" + +range-parser@^1.0.3, range-parser@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" + integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= + +raw-body@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" + integrity sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw== + dependencies: + bytes "3.0.0" + http-errors "1.6.3" + iconv-lite "0.4.23" + unpipe "1.0.0" + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +react-app-polyfill@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-0.2.2.tgz#a903b61a8bfd9c5e5f16fc63bebe44d6922a44fb" + integrity sha512-mAYn96B/nB6kWG87Ry70F4D4rsycU43VYTj3ZCbKP+SLJXwC0x6YCbwcICh3uW8/C9s1VgP197yx+w7SCWeDdQ== + dependencies: + core-js "2.6.4" + object-assign "4.1.1" + promise "8.0.2" + raf "3.4.1" + whatwg-fetch "3.0.0" + +react-dev-utils@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-8.0.0.tgz#7c5b227a45a32ea8ff7fbc318f336cf9e2c6e34c" + integrity sha512-TK8cj7eghvxfe7bfBluLGpI/upo4EXC+G74hYmPucAG8C2XcbT+vKnlWPwLnABb75Zk+mR6D556Da+yvDjljrw== + dependencies: + "@babel/code-frame" "7.0.0" + address "1.0.3" + browserslist "4.4.1" + chalk "2.4.2" + cross-spawn "6.0.5" + detect-port-alt "1.1.6" + escape-string-regexp "1.0.5" + filesize "3.6.1" + find-up "3.0.0" + fork-ts-checker-webpack-plugin "1.0.0-alpha.6" + global-modules "2.0.0" + globby "8.0.2" + gzip-size "5.0.0" + immer "1.10.0" + inquirer "6.2.1" + is-root "2.0.0" + loader-utils "1.2.3" + opn "5.4.0" + pkg-up "2.0.0" + react-error-overlay "^5.1.4" + recursive-readdir "2.2.2" + shell-quote "1.6.1" + sockjs-client "1.3.0" + strip-ansi "5.0.0" + text-table "0.2.0" + +react-dom@^16.8.6: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f" + integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.13.6" + +react-error-overlay@^5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-5.1.4.tgz#88dfb88857c18ceb3b9f95076f850d7121776991" + integrity sha512-fp+U98OMZcnduQ+NSEiQa4s/XMsbp+5KlydmkbESOw4P69iWZ68ZMFM5a2BuE0FgqPBKApJyRuYHR95jM8lAmg== + +react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" + integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-popper@^0.10.4: + version "0.10.4" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-0.10.4.tgz#af2a415ea22291edd504678d7afda8a6ee3295aa" + integrity sha1-rypBXqIike3VBGeNev2opu4ylao= + dependencies: + popper.js "^1.14.1" + prop-types "^15.6.1" + +react-redux@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.0.1.tgz#321285e6c85c3586d11cc066ab33dc580da599fb" + integrity sha512-orSiI/QXtGiiJmf8lN/zVTx4hysFo/kGOsce28IUu/mu98AGemBwPTDzf64P4Vf/miRmevO8/w2RSw2awDd21w== + dependencies: + "@babel/runtime" "^7.4.3" + hoist-non-react-statics "^3.3.0" + invariant "^2.2.4" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.8.6" + +react-router-dom@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.0.0.tgz#542a9b86af269a37f0b87218c4c25ea8dcf0c073" + integrity sha512-wSpja5g9kh5dIteZT3tUoggjnsa+TPFHSMrpHXMpFsaHhQkm/JNVGh2jiF9Dkh4+duj4MKCkwO6H08u6inZYgQ== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.0.0, react-router@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.0.0.tgz#349863f769ffc2fa10ee7331a4296e86bc12879d" + integrity sha512-6EQDakGdLG/it2x9EaCt9ZpEEPxnd0OCLBHQ1AcITAAx7nCnyvnzf76jKWG1s2/oJ7SSviUgfWHofdYljFexsA== + dependencies: + "@babel/runtime" "^7.1.2" + create-react-context "^0.2.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-scripts@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-2.1.8.tgz#21195bb928b2c0462aa98b2d32edf7d034cff2a9" + integrity sha512-mDC8fYWCyuB9VROti8OCPdHE79UEchVVZmuS/yaIs47VkvZpgZqUvzghYBswZRchqnW0aARNY8xXrzoFRhhK7A== + dependencies: + "@babel/core" "7.2.2" + "@svgr/webpack" "4.1.0" + babel-core "7.0.0-bridge.0" + babel-eslint "9.0.0" + babel-jest "23.6.0" + babel-loader "8.0.5" + babel-plugin-named-asset-import "^0.3.1" + babel-preset-react-app "^7.0.2" + bfj "6.1.1" + case-sensitive-paths-webpack-plugin "2.2.0" + css-loader "1.0.0" + dotenv "6.0.0" + dotenv-expand "4.2.0" + eslint "5.12.0" + eslint-config-react-app "^3.0.8" + eslint-loader "2.1.1" + eslint-plugin-flowtype "2.50.1" + eslint-plugin-import "2.14.0" + eslint-plugin-jsx-a11y "6.1.2" + eslint-plugin-react "7.12.4" + file-loader "2.0.0" + fs-extra "7.0.1" + html-webpack-plugin "4.0.0-alpha.2" + identity-obj-proxy "3.0.0" + jest "23.6.0" + jest-pnp-resolver "1.0.2" + jest-resolve "23.6.0" + jest-watch-typeahead "^0.2.1" + mini-css-extract-plugin "0.5.0" + optimize-css-assets-webpack-plugin "5.0.1" + pnp-webpack-plugin "1.2.1" + postcss-flexbugs-fixes "4.1.0" + postcss-loader "3.0.0" + postcss-preset-env "6.5.0" + postcss-safe-parser "4.0.1" + react-app-polyfill "^0.2.2" + react-dev-utils "^8.0.0" + resolve "1.10.0" + sass-loader "7.1.0" + style-loader "0.23.1" + terser-webpack-plugin "1.2.2" + url-loader "1.1.2" + webpack "4.28.3" + webpack-dev-server "3.1.14" + webpack-manifest-plugin "2.0.4" + workbox-webpack-plugin "3.6.3" + optionalDependencies: + fsevents "1.2.4" + +react-test-renderer@^16.0.0-0, react-test-renderer@^16.6.3: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.8.6.tgz#188d8029b8c39c786f998aa3efd3ffe7642d5ba1" + integrity sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw== + dependencies: + object-assign "^4.1.1" + prop-types "^15.6.2" + react-is "^16.8.6" + scheduler "^0.13.6" + +react-transition-group@^2.3.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + +react@^16.8.6: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" + integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.13.6" + +reactstrap@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/reactstrap/-/reactstrap-6.5.0.tgz#ba655e32646e2621829f61faa033e607ec6624e5" + integrity sha512-dWb3fB/wBAiQloteKlf+j9Nl2VLe6BMZgTEt6hpeTt0t9TwtkeU+2v2NBYONZaF4FZATfMiIKozhWpc2HmLW1g== + dependencies: + classnames "^2.2.3" + lodash.isfunction "^3.0.9" + lodash.isobject "^3.0.2" + lodash.tonumber "^4.0.3" + prop-types "^15.5.8" + react-lifecycles-compat "^3.0.4" + react-popper "^0.10.4" + react-transition-group "^2.3.1" + +read-pkg-up@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" + integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI= + dependencies: + find-up "^1.0.0" + read-pkg "^1.0.0" + +read-pkg-up@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" + integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= + dependencies: + find-up "^2.0.0" + read-pkg "^2.0.0" + +read-pkg-up@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978" + integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA== + dependencies: + find-up "^3.0.0" + read-pkg "^3.0.0" + +read-pkg@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" + integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg= + dependencies: + load-json-file "^1.0.0" + normalize-package-data "^2.3.2" + path-type "^1.0.0" + +read-pkg@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" + integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= + dependencies: + load-json-file "^2.0.0" + normalize-package-data "^2.3.2" + path-type "^2.0.0" + +read-pkg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" + integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k= + dependencies: + load-json-file "^4.0.0" + normalize-package-data "^2.3.2" + path-type "^3.0.0" + +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== + dependencies: + 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" + +readable-stream@^3.0.6, readable-stream@^3.1.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.3.0.tgz#cb8011aad002eb717bf040291feba8569c986fb9" + integrity sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" + integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== + dependencies: + graceful-fs "^4.1.11" + micromatch "^3.1.10" + readable-stream "^2.0.2" + +realpath-native@^1.0.0, realpath-native@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" + integrity sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA== + dependencies: + util.promisify "^1.0.0" + +recursive-readdir@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" + integrity sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg== + dependencies: + minimatch "3.0.4" + +reduce-reducers@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.4.3.tgz#8e052618801cd8fc2714b4915adaa8937eb6d66c" + integrity sha512-+CNMnI8QhgVMtAt54uQs3kUxC3Sybpa7Y63HR14uGLgI9/QR5ggHvpxwhGGe3wmx5V91YwqQIblN9k5lspAmGw== + +redux-actions@^2.6.4: + version "2.6.5" + resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.6.5.tgz#bdca548768ee99832a63910c276def85e821a27e" + integrity sha512-pFhEcWFTYNk7DhQgxMGnbsB1H2glqhQJRQrtPb96kD3hWiZRzXHwwmFPswg6V2MjraXRXWNmuP9P84tvdLAJmw== + dependencies: + invariant "^2.2.4" + just-curry-it "^3.1.0" + loose-envify "^1.4.0" + reduce-reducers "^0.4.3" + to-camel-case "^1.0.0" + +redux-devtools-extension@^2.13.5: + version "2.13.8" + resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1" + integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg== + +redux-logger@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" + integrity sha1-91VZZvMJjzyIYExEnPC69XeCdL8= + dependencies: + deep-diff "^0.3.5" + +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5" + integrity sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + +reflect.ownkeys@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" + integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= + +regenerate-unicode-properties@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.0.2.tgz#7b38faa296252376d363558cfbda90c9ce709662" + integrity sha512-SbA/iNrBUf6Pv2zU8Ekv1Qbhv92yxL4hiDa2siuxs4KKn4oOoMDHXjAf7+Nz9qinUQ46B1LcWEi/PhJfPWpZWQ== + dependencies: + regenerate "^1.4.0" + +regenerate@^1.2.1, regenerate@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" + integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== + +regenerator-runtime@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" + integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== + +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== + +regenerator-runtime@^0.13.2: + version "0.13.2" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz#32e59c9a6fb9b1a4aff09b4930ca2d4477343447" + integrity sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA== + +regenerator-transform@^0.13.4: + version "0.13.4" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.4.tgz#18f6763cf1382c69c36df76c6ce122cc694284fb" + integrity sha512-T0QMBjK3J0MtxjPmdIMXm72Wvj2Abb0Bd4HADdfijwMdoIsyQZ6fWC7kDFhk2YinBBEMZDL7Y7wh0J1sGx3S4A== + dependencies: + private "^0.1.6" + +regex-cache@^0.4.2: + version "0.4.4" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd" + integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ== + dependencies: + is-equal-shallow "^0.1.3" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regexp-tree@^0.1.0: + version "0.1.5" + resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.5.tgz#7cd71fca17198d04b4176efd79713f2998009397" + integrity sha512-nUmxvfJyAODw+0B13hj8CFVAxhe7fDEAgJgaotBu3nnR+IgGgZq59YedJP5VYTlkEfqjuK6TuRpnymKdatLZfQ== + +regexpp@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" + integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== + +regexpu-core@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" + integrity sha1-hqdj9Y7k18L2sQLkdkBQ3n7ZDGs= + dependencies: + regenerate "^1.2.1" + regjsgen "^0.2.0" + regjsparser "^0.1.4" + +regexpu-core@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.5.4.tgz#080d9d02289aa87fe1667a4f5136bc98a6aebaae" + integrity sha512-BtizvGtFQKGPUcTy56o3nk1bGRp4SZOTYrDtGNlqCQufptV5IkkLN6Emw+yunAJjzf+C9FQFtvq7IoA3+oMYHQ== + dependencies: + regenerate "^1.4.0" + regenerate-unicode-properties "^8.0.2" + regjsgen "^0.5.0" + regjsparser "^0.6.0" + unicode-match-property-ecmascript "^1.0.4" + unicode-match-property-value-ecmascript "^1.1.0" + +regjsgen@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" + integrity sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc= + +regjsgen@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.0.tgz#a7634dc08f89209c2049adda3525711fb97265dd" + integrity sha512-RnIrLhrXCX5ow/E5/Mh2O4e/oa1/jW0eaBKTSy3LaCj+M3Bqvm97GWDp2yUtzIs4LEn65zR2yiYGFqb2ApnzDA== + +regjsparser@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" + integrity sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw= + dependencies: + jsesc "~0.5.0" + +regjsparser@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.0.tgz#f1e6ae8b7da2bae96c99399b868cd6c933a2ba9c" + integrity sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ== + dependencies: + jsesc "~0.5.0" + +rehype-parse@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-6.0.0.tgz#f681555f2598165bee2c778b39f9073d17b16bca" + integrity sha512-V2OjMD0xcSt39G4uRdMTqDXXm6HwkUbLMDayYKA/d037j8/OtVSQ+tqKwYWOuyBeoCs/3clXRe30VUjeMDTBSA== + dependencies: + hast-util-from-parse5 "^5.0.0" + parse5 "^5.0.0" + xtend "^4.0.1" + +relateurl@0.2.x: + version "0.2.7" + resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +renderkid@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.3.tgz#380179c2ff5ae1365c522bf2fcfcff01c5b74149" + integrity sha512-z8CLQp7EZBPCwCnncgf9C4XAi3WR0dv+uWu/PjIyhhAb5d6IJ/QZqlHFprHeKT+59//V6BNUsLbvN8+2LarxGA== + dependencies: + css-select "^1.1.0" + dom-converter "^0.2" + htmlparser2 "^3.3.0" + strip-ansi "^3.0.0" + utila "^0.4.0" + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== + +repeat-string@^1.5.2, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +repeating@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" + integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= + dependencies: + is-finite "^1.0.0" + +replace-ext@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" + integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= + +request-promise-core@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.2.tgz#339f6aababcafdb31c799ff158700336301d3346" + integrity sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag== + dependencies: + lodash "^4.17.11" + +request-promise-native@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.7.tgz#a49868a624bdea5069f1251d0a836e0d89aa2c59" + integrity sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w== + dependencies: + request-promise-core "1.1.2" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.87.0: + version "2.88.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== + dependencies: + 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.0" + 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.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-from-string@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + +resolve-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" + integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= + dependencies: + resolve-from "^3.0.0" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha1-six699nWiBvItuZTM17rywoYh0g= + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-pathname@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" + integrity sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg== + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= + +resolve@1.10.0, resolve@^1.10.0, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1, resolve@^1.9.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.10.0.tgz#3bdaaeaf45cc07f375656dfd2e54ed0810b101ba" + integrity sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg== + dependencies: + path-parse "^1.0.6" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +rgb-regex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" + integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= + +rgba-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" + integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= + +rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@~2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +rst-selector-parser@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" + integrity sha1-gbIw6i/MYGbInjRy3nlChdmwPZE= + dependencies: + lodash.flattendeep "^4.4.0" + nearley "^2.7.10" + +rsvp@^3.3.3: + version "3.6.2" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a" + integrity sha512-OfWGQTb9vnwRjwtA2QwpG2ICclHC3pgXZO5xt8H2EfgDquO0qVdSb5T88L4qJVAEugbS56pAuV4XZM58UX8ulw== + +rsvp@^4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.4.tgz#b50e6b34583f3dd89329a2f23a8a2be072845911" + integrity sha512-6FomvYPfs+Jy9TfXmBpBuMWNH94SgCsZmJKcanySzgNNP6LjWxBvyLTa9KaMfDDM5oxRfrKDB0r/qeRsLwnBfA== + +run-async@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" + integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA= + dependencies: + is-promise "^2.1.0" + +run-queue@^1.0.0, run-queue@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" + integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= + dependencies: + aproba "^1.1.1" + +rxjs@^6.1.0, rxjs@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.4.0.tgz#f3bb0fe7bda7fb69deac0c16f17b50b0b8790504" + integrity sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw== + dependencies: + tslib "^1.9.0" + +safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sane@^2.0.0: + version "2.5.2" + resolved "https://registry.yarnpkg.com/sane/-/sane-2.5.2.tgz#b4dc1861c21b427e929507a3e751e2a2cb8ab3fa" + integrity sha1-tNwYYcIbQn6SlQej51HiosuKs/o= + dependencies: + anymatch "^2.0.0" + capture-exit "^1.2.0" + exec-sh "^0.2.0" + fb-watchman "^2.0.0" + micromatch "^3.1.4" + minimist "^1.1.1" + walker "~1.0.5" + watch "~0.18.0" + optionalDependencies: + fsevents "^1.2.3" + +sane@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-4.1.0.tgz#ed881fd922733a6c461bc189dc2b6c006f3ffded" + integrity sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA== + dependencies: + "@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" + +sass-loader@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.1.0.tgz#16fd5138cb8b424bf8a759528a1972d72aad069d" + integrity sha512-+G+BKGglmZM2GUSfT9TLuEp6tzehHPjAMoRRItOojWIqIGPloVCMhNIQuG639eJ+y033PaGTSjLaTHts8Kw79w== + dependencies: + clone-deep "^2.0.1" + loader-utils "^1.0.1" + lodash.tail "^4.1.1" + neo-async "^2.5.0" + pify "^3.0.0" + semver "^5.5.0" + +sax@^1.2.4, sax@~1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +scheduler@^0.13.6: + version "0.13.6" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889" + integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +schema-utils@^0.4.4: + version "0.4.7" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.7.tgz#ba74f597d2be2ea880131746ee17d0a093c68187" + integrity sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ== + dependencies: + ajv "^6.1.0" + ajv-keywords "^3.1.0" + +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + +select-hose@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" + integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= + +selfsigned@^1.9.1: + version "1.10.4" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.4.tgz#cdd7eccfca4ed7635d47a08bf2d5d3074092e2cd" + integrity sha512-9AukTiDmHXGXWtWjembZ5NDmVvP2695EtpgbCsxCa68w3c88B+alqbmZ4O3hZ4VWGXeGWzEVdvqgAJD8DQPCDw== + dependencies: + node-forge "0.7.5" + +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" + integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== + +semver@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.0.0.tgz#05e359ee571e5ad7ed641a6eec1e547ba52dea65" + integrity sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ== + +send@0.16.2: + version "0.16.2" + resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1" + integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw== + dependencies: + debug "2.6.9" + depd "~1.1.2" + destroy "~1.0.4" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "~1.6.2" + mime "1.4.1" + ms "2.0.0" + on-finished "~2.3.0" + range-parser "~1.2.0" + statuses "~1.4.0" + +serialize-javascript@^1.4.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.6.1.tgz#4d1f697ec49429a847ca6f442a2a755126c4d879" + integrity sha512-A5MOagrPFga4YaKQSWHryl7AXvbQkEqpw4NNYMTNYUNV51bA8ABHgYFpqKx+YFFrw59xMV1qGH1R4AgoNIVgCw== + +serve-index@^1.7.2: + version "1.9.1" + resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" + integrity sha1-03aNabHn2C5c4FD/9bRTvqEqkjk= + dependencies: + accepts "~1.3.4" + batch "0.6.1" + debug "2.6.9" + escape-html "~1.0.3" + http-errors "~1.6.2" + mime-types "~2.1.17" + parseurl "~1.3.2" + +serve-static@1.13.2: + version "1.13.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1" + integrity sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.2" + send "0.16.2" + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +set-value@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" + integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE= + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.1" + to-object-path "^0.3.0" + +set-value@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" + integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.4, setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + +setprototypeof@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== + +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +shallow-clone@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-0.1.2.tgz#5909e874ba77106d73ac414cfec1ffca87d97060" + integrity sha1-WQnodLp3EG1zrEFM/sH/yofZcGA= + dependencies: + is-extendable "^0.1.1" + kind-of "^2.0.1" + lazy-cache "^0.2.3" + mixin-object "^2.0.1" + +shallow-clone@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571" + integrity sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA== + dependencies: + is-extendable "^0.1.1" + kind-of "^5.0.0" + mixin-object "^2.0.1" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +shell-quote@1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767" + integrity sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c= + dependencies: + array-filter "~0.0.0" + array-map "~0.0.0" + array-reduce "~0.0.0" + jsonify "~0.0.0" + +shellwords@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + +sisteransi@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-0.1.1.tgz#5431447d5f7d1675aac667ccd0b865a4994cb3ce" + integrity sha512-PmGOd02bM9YO5ifxpw36nrNMBTptEtfRl4qUYl9SndkolplkrZZOW7PGHjrZL53QvMVj9nQ+TKqUnRsw4tJa4g== + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +slice-ansi@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" + integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== + dependencies: + ansi-styles "^3.2.0" + astral-regex "^1.0.0" + is-fullwidth-code-point "^2.0.0" + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + 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" + +sockjs-client@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.3.0.tgz#12fc9d6cb663da5739d3dc5fb6e8687da95cb177" + integrity sha512-R9jxEzhnnrdxLCNln0xg5uGHqMnkhPSTzUZH2eXcR03S/On9Yvoq2wyUZILRUhZCNVu2PmwWVoyuiPz8th8zbg== + dependencies: + debug "^3.2.5" + eventsource "^1.0.7" + faye-websocket "~0.11.1" + inherits "^2.0.3" + json3 "^3.3.2" + url-parse "^1.4.3" + +sockjs@0.3.19: + version "0.3.19" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.19.tgz#d976bbe800af7bd20ae08598d582393508993c0d" + integrity sha512-V48klKZl8T6MzatbLlzzRNhMepEys9Y4oGFpypBFFn1gLI/QQ9HtLLyWJNbPlwGLelOVOEijUbTTJeLLI59jLw== + dependencies: + faye-websocket "^0.10.0" + uuid "^3.0.1" + +source-list-map@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" + integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== + +source-map-resolve@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" + integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== + dependencies: + atob "^2.1.1" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.4.15: + version "0.4.18" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" + integrity sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA== + dependencies: + source-map "^0.5.6" + +source-map-support@^0.5.6, source-map-support@~0.5.10: + version "0.5.12" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.12.tgz#b4f3b10d51857a5af0138d3ce8003b201613d599" + integrity sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= + +source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +space-separated-tokens@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.2.tgz#e95ab9d19ae841e200808cd96bc7bd0adbbb3412" + integrity sha512-G3jprCEw+xFEs0ORweLmblJ3XLymGGr6hxZYTYZjIlvDti9vOBUjRQa1Rzjt012aRrocKstHwdNi+F7HguPsEA== + dependencies: + trim "0.0.1" + +spdx-correct@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" + integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977" + integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA== + +spdx-expression-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" + integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz#75ecd1a88de8c184ef015eafb51b5b48bfd11bb1" + integrity sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA== + +spdy-transport@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" + integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw== + dependencies: + debug "^4.1.0" + detect-node "^2.0.4" + hpack.js "^2.1.6" + obuf "^1.1.2" + readable-stream "^3.0.6" + wbuf "^1.7.3" + +spdy@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.0.tgz#81f222b5a743a329aa12cea6a390e60e9b613c52" + integrity sha512-ot0oEGT/PGUpzf/6uk4AWLqkq+irlqHXkrdbk51oWONh3bxQmBuljxPNl66zlRRcIJStWq0QkLUCPOPjgjvU0Q== + dependencies: + debug "^4.1.0" + handle-thing "^2.0.0" + http-deceiver "^1.2.7" + select-hose "^2.0.0" + spdy-transport "^3.0.0" + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + 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" + +ssri@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" + integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== + dependencies: + figgy-pudding "^3.5.1" + +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + +stack-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" + integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +"statuses@>= 1.4.0 < 2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + +statuses@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" + integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew== + +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + +stream-browserify@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" + integrity sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg== + dependencies: + inherits "~2.0.1" + readable-stream "^2.0.2" + +stream-each@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" + integrity sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw== + dependencies: + end-of-stream "^1.1.0" + stream-shift "^1.0.0" + +stream-http@^2.7.2: + version "2.8.3" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" + integrity sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw== + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.1" + readable-stream "^2.3.6" + to-arraybuffer "^1.0.0" + xtend "^4.0.0" + +stream-shift@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" + integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI= + +string-length@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" + integrity sha1-1A27aGo6zpYMHP/KVivyxF+DY+0= + dependencies: + astral-regex "^1.0.0" + strip-ansi "^4.0.0" + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string.prototype.trim@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea" + integrity sha1-0E3iyJ4Tf019IG8Ia17S+ua+jOo= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.0" + function-bind "^1.0.2" + +string_decoder@^1.0.0, string_decoder@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d" + integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w== + dependencies: + safe-buffer "~5.1.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +stringify-object@^3.2.2: + version "3.3.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" + integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== + dependencies: + get-own-enumerable-property-symbols "^3.0.0" + is-obj "^1.0.1" + is-regexp "^1.0.0" + +strip-ansi@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.0.0.tgz#f78f68b5d0866c20b2c9b8c61b5298508dc8756f" + integrity sha512-Uu7gQyZI7J7gn5qLn1Np3G9vcYGTVqB+lFTytnDJv83dd8T22aGH451P3jueT2/QemInJDfxHB5Tde5OzgG1Ow== + dependencies: + ansi-regex "^4.0.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-bom@3.0.0, strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-bom@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" + integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= + dependencies: + is-utf8 "^0.2.0" + +strip-comments@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/strip-comments/-/strip-comments-1.0.2.tgz#82b9c45e7f05873bee53f37168af930aa368679d" + integrity sha512-kL97alc47hoyIQSV165tTt9rG5dn4w1dNnBhOQ3bOU1Nc1hel09jnXANaHJ7vzHLd4Ju8kseDGzlev96pghLFw== + dependencies: + babel-extract-comments "^1.0.0" + babel-plugin-transform-object-rest-spread "^6.26.0" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +style-loader@0.23.1: + version "0.23.1" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.1.tgz#cb9154606f3e771ab6c4ab637026a1049174d925" + integrity sha512-XK+uv9kWwhZMZ1y7mysB+zoihsEj4wneFWAS5qoiLwzW0WzSqMrrsIy+a3zkQJq0ipFtBpX5W3MqyRIBF/WFGg== + dependencies: + loader-utils "^1.1.0" + schema-utils "^1.0.0" + +stylehacks@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" + integrity sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +superagent@^3.8.2: + version "3.8.3" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" + integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA== + dependencies: + 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" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^3.1.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" + integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY= + dependencies: + has-flag "^1.0.0" + +supports-color@^5.1.0, supports-color@^5.3.0, supports-color@^5.4.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + +svgo@^1.0.0, svgo@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.2.1.tgz#3fedde75a4016193e1c2608b5fdef6f3e4a9fd99" + integrity sha512-Y1+LyT4/y1ms4/0yxPMSlvx6dIbgklE9w8CIOnfeoFGB74MEkq8inSfEr6NhocTaFbyYp0a1dvNgRKGRmEBlzA== + dependencies: + chalk "^2.4.1" + coa "^2.0.2" + css-select "^2.0.0" + css-select-base-adapter "^0.1.1" + css-tree "1.0.0-alpha.28" + css-url-regex "^1.1.0" + csso "^3.5.1" + js-yaml "^3.13.0" + mkdirp "~0.5.1" + object.values "^1.1.0" + sax "~1.2.4" + stable "^0.1.8" + unquote "~1.1.1" + util.promisify "~1.0.0" + +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + +symbol-tree@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" + integrity sha1-rifbOPZgp64uHDt9G8KQgZuFGeY= + +table@^5.0.2: + version "5.2.3" + resolved "https://registry.yarnpkg.com/table/-/table-5.2.3.tgz#cde0cc6eb06751c009efab27e8c820ca5b67b7f2" + integrity sha512-N2RsDAMvDLvYwFcwbPyF3VmVSSkuF+G1e+8inhBLtHpvwXGw4QRPEZhihQNeEN0i1up6/f6ObCJXNdlRG3YVyQ== + dependencies: + ajv "^6.9.1" + lodash "^4.17.11" + slice-ansi "^2.1.0" + string-width "^3.0.0" + +tapable@^1.0.0, tapable@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" + integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== + +tar@^4: + version "4.4.8" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" + integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ== + dependencies: + chownr "^1.1.1" + fs-minipass "^1.2.5" + minipass "^2.3.4" + minizlib "^1.1.1" + mkdirp "^0.5.0" + safe-buffer "^5.1.2" + yallist "^3.0.2" + +terser-webpack-plugin@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.2.2.tgz#9bff3a891ad614855a7dde0d707f7db5a927e3d9" + integrity sha512-1DMkTk286BzmfylAvLXwpJrI7dWa5BnFmscV/2dCr8+c56egFcbaeFAl7+sujAjdmpLam21XRdhA4oifLyiWWg== + dependencies: + cacache "^11.0.2" + find-cache-dir "^2.0.0" + schema-utils "^1.0.0" + serialize-javascript "^1.4.0" + source-map "^0.6.1" + terser "^3.16.1" + webpack-sources "^1.1.0" + worker-farm "^1.5.2" + +terser-webpack-plugin@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.2.3.tgz#3f98bc902fac3e5d0de730869f50668561262ec8" + integrity sha512-GOK7q85oAb/5kE12fMuLdn2btOS9OBZn4VsecpHDywoUC/jLhSAKOiYo0ezx7ss2EXPMzyEWFoE0s1WLE+4+oA== + dependencies: + cacache "^11.0.2" + find-cache-dir "^2.0.0" + schema-utils "^1.0.0" + serialize-javascript "^1.4.0" + source-map "^0.6.1" + terser "^3.16.1" + webpack-sources "^1.1.0" + worker-farm "^1.5.2" + +terser@^3.16.1: + version "3.17.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-3.17.0.tgz#f88ffbeda0deb5637f9d24b0da66f4e15ab10cb2" + integrity sha512-/FQzzPJmCpjAH9Xvk2paiWrFq+5M6aVOf+2KRbwhByISDX/EujxsK+BAvrhb6H+2rtrLCHK9N01wO014vrIwVQ== + dependencies: + commander "^2.19.0" + source-map "~0.6.1" + source-map-support "~0.5.10" + +test-exclude@^4.2.1: + version "4.2.3" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-4.2.3.tgz#a9a5e64474e4398339245a0a769ad7c2f4a97c20" + integrity sha512-SYbXgY64PT+4GAL2ocI3HwPa4Q4TBKm0cwAVeKOt/Aoc0gSpNRjJX8w0pA1LMKZ3LBmd8pYBqApFNQLII9kavA== + dependencies: + arrify "^1.0.1" + micromatch "^2.3.11" + object-assign "^4.1.0" + read-pkg-up "^1.0.1" + require-main-filename "^1.0.1" + +test-exclude@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.1.0.tgz#6ba6b25179d2d38724824661323b73e03c0c1de1" + integrity sha512-gwf0S2fFsANC55fSeSqpb8BYk6w3FDvwZxfNjeF6FRgvFa43r+7wRiA/Q0IxoRU37wB/LE8IQ4221BsNucTaCA== + dependencies: + arrify "^1.0.1" + minimatch "^3.0.4" + read-pkg-up "^4.0.0" + require-main-filename "^1.0.1" + +text-table@0.2.0, text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= + +throat@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" + integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= + +through2@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +through@^2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + +thunky@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.0.3.tgz#f5df732453407b09191dae73e2a8cc73f381a826" + integrity sha512-YwT8pjmNcAXBZqrubu22P4FYsh2D4dxRmnWBOL8Jk8bUcRUtc5326kx32tuTmFDAZtLOGEVNl8POAR8j896Iow== + +timers-browserify@^2.0.4: + version "2.0.10" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.10.tgz#1d28e3d2aadf1d5a5996c4e9f95601cd053480ae" + integrity sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg== + dependencies: + setimmediate "^1.0.4" + +timsort@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" + integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= + +tiny-invariant@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.4.tgz#346b5415fd93cb696b0c4e8a96697ff590f92463" + integrity sha512-lMhRd/djQJ3MoaHEBrw8e2/uM4rs9YMNk0iOr8rHQ0QdbM7D4l0gFl3szKdeixrlyfm9Zqi4dxHCM2qVG8ND5g== + +tiny-warning@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.2.tgz#1dfae771ee1a04396bdfde27a3adcebc6b648b28" + integrity sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q== + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + +to-arraybuffer@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" + integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= + +to-camel-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46" + integrity sha1-GlYFSy+daWKYzmamCJcyK29CPkY= + dependencies: + to-space-case "^1.0.0" + +to-fast-properties@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" + integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc= + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-no-case@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" + integrity sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +to-space-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17" + integrity sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc= + dependencies: + to-no-case "^1.0.0" + +topo@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/topo/-/topo-2.0.2.tgz#cd5615752539057c0dc0491a621c3bc6fbe1d182" + integrity sha1-zVYVdSU5BXwNwEkaYhw7xvvh0YI= + dependencies: + hoek "4.x.x" + +tough-cookie@^2.3.3, tough-cookie@^2.3.4: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tough-cookie@~2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== + dependencies: + psl "^1.1.24" + punycode "^1.4.1" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk= + dependencies: + punycode "^2.1.0" + +trim-right@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" + integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= + +trim@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" + integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= + +trough@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.3.tgz#e29bd1614c6458d44869fc28b255ab7857ef7c24" + integrity sha512-fwkLWH+DimvA4YCy+/nvJd61nWQQ2liO/nF/RjkTpiOGi+zxZzVkhb1mvbHIIW4b/8nDsYI8uTmAlc0nNkRMOw== + +tryer@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" + integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== + +ts-pnp@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.0.1.tgz#fde74a6371676a167abaeda1ffc0fdb423520098" + integrity sha512-Zzg9XH0anaqhNSlDRibNC8Kp+B9KNM0uRIpLpGkGyrgRIttA7zZBhotTSEoEyuDrz3QW2LGtu2dxuk34HzIGnQ== + +tslib@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" + integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== + +tty-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" + integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +type-is@~1.6.16: + version "1.6.16" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194" + integrity sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.18" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +ua-parser-js@^0.7.18: + version "0.7.19" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b" + integrity sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ== + +uglify-js@3.4.x: + version "3.4.10" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f" + integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw== + dependencies: + commander "~2.19.0" + source-map "~0.6.1" + +uglify-js@^3.1.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.5.4.tgz#4a64d57f590e20a898ba057f838dcdfb67a939b9" + integrity sha512-GpKo28q/7Bm5BcX9vOu4S46FwisbPbAmkkqPnGIpKvKTM96I85N6XHQV+k4I6FA2wxgLhcsSyHoNhzucwCflvA== + dependencies: + commander "~2.20.0" + source-map "~0.6.1" + +unicode-canonical-property-names-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" + integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ== + +unicode-match-property-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c" + integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg== + dependencies: + unicode-canonical-property-names-ecmascript "^1.0.4" + unicode-property-aliases-ecmascript "^1.0.4" + +unicode-match-property-value-ecmascript@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277" + integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g== + +unicode-property-aliases-ecmascript@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57" + integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw== + +unified@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/unified/-/unified-7.1.0.tgz#5032f1c1ee3364bd09da12e27fdd4a7553c7be13" + integrity sha512-lbk82UOIGuCEsZhPj8rNAkXSDXd6p0QLzIuSsCdxrqnqU56St4eyOB+AlXsVgVeRmetPTYydIuvFfpDIed8mqw== + dependencies: + "@types/unist" "^2.0.0" + "@types/vfile" "^3.0.0" + bail "^1.0.0" + extend "^3.0.0" + is-plain-obj "^1.1.0" + trough "^1.0.0" + vfile "^3.0.0" + x-is-string "^0.1.0" + +union-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" + integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ= + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^0.4.3" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= + +uniqs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" + integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= + +unique-filename@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" + integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.1.tgz#5e9edc6d1ce8fb264db18a507ef9bd8544451ca6" + integrity sha512-n9cU6+gITaVu7VGj1Z8feKMmfAjEAQGhwD9fE3zvpRRa0wEIx8ODYkVGfSc94M2OX00tUFV8wH3zYbm1I8mxFg== + dependencies: + imurmurhash "^0.1.4" + +unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" + integrity sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ== + +unist-util-stringify-position@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-2.0.0.tgz#4c452c0dbcbc509f7bcd366e9a8afd646f9d51ae" + integrity sha512-Uz5negUTrf9zm2ZT2Z9kdOL7Mr7FJLyq3ByqagUi7QZRVK1HnspVazvSqwHt73jj7APHtpuJ4K110Jm8O6/elw== + dependencies: + "@types/unist" "^2.0.2" + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + +unquote@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" + integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +upath@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" + integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== + +upper-case@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" + integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg= + +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +url-join@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-1.1.0.tgz#741c6c2f4596c4830d6718460920d0c92202dc78" + integrity sha1-dBxsL0WWxIMNZxhGCSDQySIC3Hg= + +url-join@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a" + integrity sha1-TTNA6AfTdzvamZH4MFrNzCpmXSo= + +url-loader@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-1.1.2.tgz#b971d191b83af693c5e3fea4064be9e1f2d7f8d8" + integrity sha512-dXHkKmw8FhPqu8asTc1puBfe3TehOCo2+RmOOev5suNCIYBcT626kxiWg1NBVkwc4rO8BGa7gP70W7VXuqHrjg== + dependencies: + loader-utils "^1.1.0" + mime "^2.0.3" + schema-utils "^1.0.0" + +url-parse@^1.4.3: + version "1.4.5" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.5.tgz#04cbb6ba2be682a18e4417fa2245aa7e3dfdcc50" + integrity sha512-4XDvC5vZRjEpjP0L4znrWeoH8P8F0XGBlfLdABi/6oV4o8xUVbTpyrxWHxkK2bT0pSIpcjdIzSoWUhlUfawCAQ== + dependencies: + querystringify "^2.0.0" + requires-port "^1.0.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util.promisify@1.0.0, util.promisify@^1.0.0, util.promisify@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + integrity sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + +util@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= + dependencies: + inherits "2.0.1" + +util@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" + integrity sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ== + dependencies: + inherits "2.0.3" + +utila@^0.4.0, utila@~0.4: + version "0.4.0" + resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" + integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + +uuid@^3.0.1, uuid@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +value-equal@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" + integrity sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw== + +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + +vendors@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.2.tgz#7fcb5eef9f5623b156bcea89ec37d63676f21801" + integrity sha512-w/hry/368nO21AN9QljsaIhb9ZiZtZARoVH5f3CsFbawdLdayCgKRPup7CggujvySMxx0I91NOyxdVENohprLQ== + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +vfile-message@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.1.1.tgz#5833ae078a1dfa2d96e9647886cd32993ab313e1" + integrity sha512-1WmsopSGhWt5laNir+633LszXvZ+Z/lxveBf6yhGsqnQIhlhzooZae7zV6YVM1Sdkw68dtAW3ow0pOdPANugvA== + dependencies: + unist-util-stringify-position "^1.1.1" + +vfile-message@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.0.tgz#750bbb86fe545988a67e899b329bbcabb73edef6" + integrity sha512-YS6qg6UpBfIeiO+6XlhPOuJaoLvt1Y9g2cmlwqhBOOU0XRV8j5RLeoz72t6PWLvNXq3EBG1fQ05wNPrUoz0deQ== + dependencies: + "@types/unist" "^2.0.2" + unist-util-stringify-position "^1.1.1" + +vfile@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-3.0.1.tgz#47331d2abe3282424f4a4bb6acd20a44c4121803" + integrity sha512-y7Y3gH9BsUSdD4KzHsuMaCzRjglXN0W2EcMf0gpvu6+SbsGhMje7xDc8AEoeXy6mIwCKMI6BkjMsRjzQbhMEjQ== + dependencies: + is-buffer "^2.0.0" + replace-ext "1.0.0" + unist-util-stringify-position "^1.0.0" + vfile-message "^1.0.0" + +vfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.0.0.tgz#ebf3b48af9fcde524d5e08d5f75812058a5f78ad" + integrity sha512-WMNeHy5djSl895BqE86D7WqA0Ie5fAIeGCa7V1EqiXyJg5LaGch2SUaZueok5abYQGH6mXEAsZ45jkoILIOlyA== + dependencies: + "@types/unist" "^2.0.2" + is-buffer "^2.0.0" + replace-ext "1.0.0" + unist-util-stringify-position "^2.0.0" + vfile-message "^2.0.0" + +vm-browserify@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" + integrity sha1-XX6kW7755Kb/ZflUOOCofDV9WnM= + dependencies: + indexof "0.0.1" + +w3c-hr-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" + integrity sha1-gqwr/2PZUOqeMYmlimViX+3xkEU= + dependencies: + browser-process-hrtime "^0.1.2" + +walker@^1.0.7, walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= + dependencies: + makeerror "1.0.x" + +watch@~0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986" + integrity sha1-KAlUdsbffJDJYxOJkMClQj60uYY= + dependencies: + exec-sh "^0.2.0" + minimist "^1.2.0" + +watchpack@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" + integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== + dependencies: + chokidar "^2.0.2" + graceful-fs "^4.1.2" + neo-async "^2.5.0" + +wbuf@^1.1.0, wbuf@^1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" + integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== + dependencies: + minimalistic-assert "^1.0.0" + +web-namespaces@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.2.tgz#c8dc267ab639505276bae19e129dbd6ae72b22b4" + integrity sha512-II+n2ms4mPxK+RnIxRPOw3zwF2jRscdJIUE9BfkKHm4FYEg9+biIoTMnaZF5MpemE3T+VhMLrhbyD4ilkPCSbg== + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +webpack-dev-middleware@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.4.0.tgz#1132fecc9026fd90f0ecedac5cbff75d1fb45890" + integrity sha512-Q9Iyc0X9dP9bAsYskAVJ/hmIZZQwf/3Sy4xCAZgL5cUkjZmUZLt4l5HpbST/Pdgjn3u6pE7u5OdGd1apgzRujA== + dependencies: + memory-fs "~0.4.1" + mime "^2.3.1" + range-parser "^1.0.3" + webpack-log "^2.0.0" + +webpack-dev-server@3.1.14: + version "3.1.14" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.1.14.tgz#60fb229b997fc5a0a1fc6237421030180959d469" + integrity sha512-mGXDgz5SlTxcF3hUpfC8hrQ11yhAttuUQWf1Wmb+6zo3x6rb7b9mIfuQvAPLdfDRCGRGvakBWHdHOa0I9p/EVQ== + dependencies: + ansi-html "0.0.7" + bonjour "^3.5.0" + chokidar "^2.0.0" + compression "^1.5.2" + connect-history-api-fallback "^1.3.0" + debug "^3.1.0" + del "^3.0.0" + express "^4.16.2" + html-entities "^1.2.0" + http-proxy-middleware "~0.18.0" + import-local "^2.0.0" + internal-ip "^3.0.1" + ip "^1.1.5" + killable "^1.0.0" + loglevel "^1.4.1" + opn "^5.1.0" + portfinder "^1.0.9" + schema-utils "^1.0.0" + selfsigned "^1.9.1" + semver "^5.6.0" + serve-index "^1.7.2" + sockjs "0.3.19" + sockjs-client "1.3.0" + spdy "^4.0.0" + strip-ansi "^3.0.0" + supports-color "^5.1.0" + url "^0.11.0" + webpack-dev-middleware "3.4.0" + webpack-log "^2.0.0" + yargs "12.0.2" + +webpack-log@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" + integrity sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg== + dependencies: + ansi-colors "^3.0.0" + uuid "^3.3.2" + +webpack-manifest-plugin@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/webpack-manifest-plugin/-/webpack-manifest-plugin-2.0.4.tgz#e4ca2999b09557716b8ba4475fb79fab5986f0cd" + integrity sha512-nejhOHexXDBKQOj/5v5IZSfCeTO3x1Dt1RZEcGfBSul891X/eLIcIVH31gwxPDdsi2Z8LKKFGpM4w9+oTBOSCg== + dependencies: + fs-extra "^7.0.0" + lodash ">=3.5 <5" + tapable "^1.0.0" + +webpack-sources@^1.1.0, webpack-sources@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85" + integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA== + dependencies: + source-list-map "^2.0.0" + source-map "~0.6.1" + +webpack@4.28.3: + version "4.28.3" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.28.3.tgz#8acef6e77fad8a01bfd0c2b25aa3636d46511874" + integrity sha512-vLZN9k5I7Nr/XB1IDG9GbZB4yQd1sPuvufMFgJkx0b31fi2LD97KQIjwjxE7xytdruAYfu5S0FLBLjdxmwGJCg== + dependencies: + "@webassemblyjs/ast" "1.7.11" + "@webassemblyjs/helper-module-context" "1.7.11" + "@webassemblyjs/wasm-edit" "1.7.11" + "@webassemblyjs/wasm-parser" "1.7.11" + acorn "^5.6.2" + acorn-dynamic-import "^3.0.0" + ajv "^6.1.0" + ajv-keywords "^3.1.0" + chrome-trace-event "^1.0.0" + enhanced-resolve "^4.1.0" + eslint-scope "^4.0.0" + json-parse-better-errors "^1.0.2" + loader-runner "^2.3.0" + loader-utils "^1.1.0" + memory-fs "~0.4.1" + micromatch "^3.1.8" + mkdirp "~0.5.0" + neo-async "^2.5.0" + node-libs-browser "^2.0.0" + schema-utils "^0.4.4" + tapable "^1.1.0" + terser-webpack-plugin "^1.1.0" + watchpack "^1.5.0" + webpack-sources "^1.3.0" + +websocket-driver@>=0.5.1: + version "0.7.0" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb" + integrity sha1-DK+dLXVdk67gSdS90NP+LMoqJOs= + dependencies: + http-parser-js ">=0.4.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" + integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== + +whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: + version "1.0.5" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" + integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== + dependencies: + iconv-lite "0.4.24" + +whatwg-fetch@3.0.0, whatwg-fetch@>=0.10.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q== + +whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" + integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== + +whatwg-url@^6.4.1: + version "6.5.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" + integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +whatwg-url@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.0.0.tgz#fde926fa54a599f3adf82dff25a9f7be02dc6edd" + integrity sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which@^1.2.12, which@^1.2.9, which@^1.3.0, which@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +winchan@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/winchan/-/winchan-0.2.1.tgz#19b334e49f7c07c0849f921f405fad87dfc8a1da" + integrity sha512-QrG9q+ObfmZBxScv0HSCqFm/owcgyR5Sgpiy1NlCZPpFXhbsmNHhTiLWoogItdBUi0fnU7Io/5ABEqRta5/6Dw== + +wordwrap@~0.0.2: + version "0.0.3" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" + integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= + +wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + +workbox-background-sync@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-3.6.3.tgz#6609a0fac9eda336a7c52e6aa227ba2ae532ad94" + integrity sha512-ypLo0B6dces4gSpaslmDg5wuoUWrHHVJfFWwl1udvSylLdXvnrfhFfriCS42SNEe5lsZtcNZF27W/SMzBlva7Q== + dependencies: + workbox-core "^3.6.3" + +workbox-broadcast-cache-update@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-broadcast-cache-update/-/workbox-broadcast-cache-update-3.6.3.tgz#3f5dff22ada8c93e397fb38c1dc100606a7b92da" + integrity sha512-pJl4lbClQcvp0SyTiEw0zLSsVYE1RDlCPtpKnpMjxFtu8lCFTAEuVyzxp9w7GF4/b3P4h5nyQ+q7V9mIR7YzGg== + dependencies: + workbox-core "^3.6.3" + +workbox-build@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-3.6.3.tgz#77110f9f52dc5d82fa6c1c384c6f5e2225adcbd8" + integrity sha512-w0clZ/pVjL8VXy6GfthefxpEXs0T8uiRuopZSFVQ8ovfbH6c6kUpEh6DcYwm/Y6dyWPiCucdyAZotgjz+nRz8g== + dependencies: + babel-runtime "^6.26.0" + common-tags "^1.4.0" + fs-extra "^4.0.2" + glob "^7.1.2" + joi "^11.1.1" + lodash.template "^4.4.0" + pretty-bytes "^4.0.2" + stringify-object "^3.2.2" + strip-comments "^1.0.2" + workbox-background-sync "^3.6.3" + workbox-broadcast-cache-update "^3.6.3" + workbox-cache-expiration "^3.6.3" + workbox-cacheable-response "^3.6.3" + workbox-core "^3.6.3" + workbox-google-analytics "^3.6.3" + workbox-navigation-preload "^3.6.3" + workbox-precaching "^3.6.3" + workbox-range-requests "^3.6.3" + workbox-routing "^3.6.3" + workbox-strategies "^3.6.3" + workbox-streams "^3.6.3" + workbox-sw "^3.6.3" + +workbox-cache-expiration@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-cache-expiration/-/workbox-cache-expiration-3.6.3.tgz#4819697254a72098a13f94b594325a28a1e90372" + integrity sha512-+ECNph/6doYx89oopO/UolYdDmQtGUgo8KCgluwBF/RieyA1ZOFKfrSiNjztxOrGJoyBB7raTIOlEEwZ1LaHoA== + dependencies: + workbox-core "^3.6.3" + +workbox-cacheable-response@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-3.6.3.tgz#869f1a68fce9063f6869ddbf7fa0a2e0a868b3aa" + integrity sha512-QpmbGA9SLcA7fklBLm06C4zFg577Dt8u3QgLM0eMnnbaVv3rhm4vbmDpBkyTqvgK/Ly8MBDQzlXDtUCswQwqqg== + dependencies: + workbox-core "^3.6.3" + +workbox-core@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-3.6.3.tgz#69abba70a4f3f2a5c059295a6f3b7c62bd00e15c" + integrity sha512-cx9cx0nscPkIWs8Pt98HGrS9/aORuUcSkWjG25GqNWdvD/pSe7/5Oh3BKs0fC+rUshCiyLbxW54q0hA+GqZeSQ== + +workbox-google-analytics@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-3.6.3.tgz#99df2a3d70d6e91961e18a6752bac12e91fbf727" + integrity sha512-RQBUo/6SXtIaQTRFj4RQZ9e1gAl7D8oS5S+Hi173Kk70/BgJjzPwXpC5A249Jv5YfkCOLMQCeF9A27BiD0b0ig== + dependencies: + workbox-background-sync "^3.6.3" + workbox-core "^3.6.3" + workbox-routing "^3.6.3" + workbox-strategies "^3.6.3" + +workbox-navigation-preload@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-3.6.3.tgz#a2c34eb7c17e7485b795125091215f757b3c4964" + integrity sha512-dd26xTX16DUu0i+MhqZK/jQXgfIitu0yATM4jhRXEmpMqQ4MxEeNvl2CgjDMOHBnCVMax+CFZQWwxMx/X/PqCw== + dependencies: + workbox-core "^3.6.3" + +workbox-precaching@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-3.6.3.tgz#5341515e9d5872c58ede026a31e19bafafa4e1c1" + integrity sha512-aBqT66BuMFviPTW6IpccZZHzpA8xzvZU2OM1AdhmSlYDXOJyb1+Z6blVD7z2Q8VNtV1UVwQIdImIX+hH3C3PIw== + dependencies: + workbox-core "^3.6.3" + +workbox-range-requests@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-3.6.3.tgz#3cc21cba31f2dd8c43c52a196bcc8f6cdbcde803" + integrity sha512-R+yLWQy7D9aRF9yJ3QzwYnGFnGDhMUij4jVBUVtkl67oaVoP1ymZ81AfCmfZro2kpPRI+vmNMfxxW531cqdx8A== + dependencies: + workbox-core "^3.6.3" + +workbox-routing@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-3.6.3.tgz#659cd8f9274986cfa98fda0d050de6422075acf7" + integrity sha512-bX20i95OKXXQovXhFOViOK63HYmXvsIwZXKWbSpVeKToxMrp0G/6LZXnhg82ijj/S5yhKNRf9LeGDzaqxzAwMQ== + dependencies: + workbox-core "^3.6.3" + +workbox-strategies@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-3.6.3.tgz#11a0dc249a7bc23d3465ec1322d28fa6643d64a0" + integrity sha512-Pg5eulqeKet2y8j73Yw6xTgLdElktcWExGkzDVCGqfV9JCvnGuEpz5eVsCIK70+k4oJcBCin9qEg3g3CwEIH3g== + dependencies: + workbox-core "^3.6.3" + +workbox-streams@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-3.6.3.tgz#beaea5d5b230239836cc327b07d471aa6101955a" + integrity sha512-rqDuS4duj+3aZUYI1LsrD2t9hHOjwPqnUIfrXSOxSVjVn83W2MisDF2Bj+dFUZv4GalL9xqErcFW++9gH+Z27w== + dependencies: + workbox-core "^3.6.3" + +workbox-sw@^3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-3.6.3.tgz#278ea4c1831b92bbe2d420da8399176c4b2789ff" + integrity sha512-IQOUi+RLhvYCiv80RP23KBW/NTtIvzvjex28B8NW1jOm+iV4VIu3VXKXTA6er5/wjjuhmtB28qEAUqADLAyOSg== + +workbox-webpack-plugin@3.6.3: + version "3.6.3" + resolved "https://registry.yarnpkg.com/workbox-webpack-plugin/-/workbox-webpack-plugin-3.6.3.tgz#a807bb891b4e4e3c808df07e58f17de2d5ba6182" + integrity sha512-RwmKjc7HFHUFHoOlKoZUq9349u0QN3F8W5tZZU0vc1qsBZDINWXRiIBCAKvo/Njgay5sWz7z4I2adnyTo97qIQ== + dependencies: + babel-runtime "^6.26.0" + json-stable-stringify "^1.0.1" + workbox-build "^3.6.3" + +worker-farm@^1.5.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.6.0.tgz#aecc405976fab5a95526180846f0dba288f3a4a0" + integrity sha512-6w+3tHbM87WnSWnENBUvA2pxJPLhQUg5LKwUQHq3r+XPhIM+Gh2R5ycbwPCyuGbNg+lPgdcnQUhuC02kJCvffQ== + dependencies: + errno "~0.1.7" + +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.1.tgz#d0b05463c188ae804396fd5ab2a370062af87529" + integrity sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg== + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + signal-exit "^3.0.2" + +write-file-atomic@^2.1.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.2.tgz#a7181706dfba17855d221140a9c06e15fcdd87b9" + integrity sha512-s0b6vB3xIVRLWywa6X9TOMA7k9zio0TMOsl9ZnDkliA/cfJlpHXAscj0gbHVJiTdIuAYpIyqS5GW91fqm6gG5g== + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + signal-exit "^3.0.2" + +write@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" + integrity sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c= + dependencies: + mkdirp "^0.5.1" + +ws@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f" + integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA== + dependencies: + async-limiter "~1.0.0" + +x-is-string@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" + integrity sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI= + +xml-name-validator@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" + integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== + +xregexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.0.0.tgz#e698189de49dd2a18cc5687b05e17c8e43943020" + integrity sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg== + +xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" + integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= + +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + +"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + +yallist@^3.0.0, yallist@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" + integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A== + +yargs-parser@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" + integrity sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ== + dependencies: + camelcase "^4.1.0" + +yargs-parser@^9.0.2: + version "9.0.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-9.0.2.tgz#9ccf6a43460fe4ed40a9bb68f48d43b8a68cc077" + integrity sha1-nM9qQ0YP5O1Aqbto9I1DuKaMwHc= + dependencies: + camelcase "^4.1.0" + +yargs@12.0.2: + version "12.0.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.2.tgz#fe58234369392af33ecbef53819171eff0f5aadc" + integrity sha512-e7SkEx6N6SIZ5c5H22RTZae61qtn3PYUE8JYbBFlK9sYmh3DMQ6E5ygtaG/2BW0JZi4WGgTR2IV5ChqlqrDGVQ== + dependencies: + cliui "^4.0.0" + decamelize "^2.0.0" + find-up "^3.0.0" + get-caller-file "^1.0.1" + os-locale "^3.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1 || ^4.0.0" + yargs-parser "^10.1.0" + +yargs@^11.0.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.0.tgz#90b869934ed6e871115ea2ff58b03f4724ed2d77" + integrity sha512-NwW69J42EsCSanF8kyn5upxvjp5ds+t3+udGBeTbFnERA+lF541DDpMawzo4z6W/QrzNM18D+BPMiOBibnFV5A== + dependencies: + cliui "^4.0.0" + decamelize "^1.1.1" + find-up "^2.1.0" + get-caller-file "^1.0.1" + os-locale "^2.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1" + yargs-parser "^9.0.2" diff --git a/imei-lookup/build.gradle b/imei-lookup/build.gradle index 7fd551beb..ce6375222 100644 --- a/imei-lookup/build.gradle +++ b/imei-lookup/build.gradle @@ -1,15 +1,16 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" id "idea" } dependencies { implementation project(":prime-modules") - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" -} \ No newline at end of file +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiDb.kt b/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiDb.kt index 4d7c95adf..7c15a6338 100644 --- a/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiDb.kt +++ b/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiDb.kt @@ -30,7 +30,7 @@ object ImeiDdSingleton : ImeiLookup { private val logger by getLogger() - val db = HashMap() + private val db = HashMap() override fun getImeiInformation(imei: String): Either { @@ -40,7 +40,7 @@ object ImeiDdSingleton : ImeiLookup { val tac = imei.substring(0, 8) - val imeiInformation = db.get(tac) + val imeiInformation = db[tac] if (imeiInformation != null) { return Either.right(imeiInformation) } diff --git a/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiLookupModule.kt b/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiLookupModule.kt index 3e1ce82bd..67743e6ab 100644 --- a/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiLookupModule.kt +++ b/imei-lookup/src/main/kotlin/org/ostelco/prime/imei/imeilookup/ImeiLookupModule.kt @@ -13,11 +13,8 @@ class ImeiLookupModule : PrimeModule { lateinit var config: Config override fun init(env: Environment) { - ImeiDdSingleton.loadFile(config.csvFile); + ImeiDdSingleton.loadFile(config.csvFile) } } -class Config { - @JsonProperty - lateinit var csvFile: String -} \ No newline at end of file +data class Config(val csvFile: String) \ No newline at end of file diff --git a/imei-lookup/src/test/kotlin/org/ostelco/prime/imei/imeilookup/ImeiInmemoryDbTest.kt b/imei-lookup/src/test/kotlin/org/ostelco/prime/imei/imeilookup/ImeiInmemoryDbTest.kt index 48d84821f..fa429d7ed 100644 --- a/imei-lookup/src/test/kotlin/org/ostelco/prime/imei/imeilookup/ImeiInmemoryDbTest.kt +++ b/imei-lookup/src/test/kotlin/org/ostelco/prime/imei/imeilookup/ImeiInmemoryDbTest.kt @@ -1,6 +1,7 @@ package org.ostelco.prime.imei.imeilookup import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.KotlinModule import io.dropwizard.Application import io.dropwizard.Configuration import io.dropwizard.configuration.EnvironmentVariableSubstitutor @@ -20,6 +21,7 @@ class TestApp : Application() { bootstrap.configurationSourceProvider = SubstitutingSourceProvider( bootstrap.configurationSourceProvider, EnvironmentVariableSubstitutor(false)) + bootstrap.objectMapper.registerModule(KotlinModule()) } override fun run(configuration: TestConfig, environment: Environment) { diff --git a/jersey/build.gradle b/jersey/build.gradle index bf35cb3f9..091fb6025 100644 --- a/jersey/build.gradle +++ b/jersey/build.gradle @@ -1,8 +1,14 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } dependencies { implementation project(":prime-modules") -} \ No newline at end of file + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation "io.jsonwebtoken:jjwt:$jjwtVersion" +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/jersey/src/main/kotlin/org/ostelco/prime/jersey/JerseyModule.kt b/jersey/src/main/kotlin/org/ostelco/prime/jersey/JerseyModule.kt index 23845922d..4268a1b97 100644 --- a/jersey/src/main/kotlin/org/ostelco/prime/jersey/JerseyModule.kt +++ b/jersey/src/main/kotlin/org/ostelco/prime/jersey/JerseyModule.kt @@ -1,17 +1,78 @@ package org.ostelco.prime.jersey +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.google.common.cache.CacheBuilderSpec +import io.dropwizard.auth.AuthDynamicFeature +import io.dropwizard.auth.AuthValueFactoryProvider +import io.dropwizard.auth.CachingAuthenticator +import io.dropwizard.auth.oauth.OAuthCredentialAuthFilter.Builder +import io.dropwizard.client.JerseyClientBuilder +import io.dropwizard.client.JerseyClientConfiguration import io.dropwizard.setup.Environment +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator import org.ostelco.prime.module.PrimeModule +import javax.validation.Valid +import javax.validation.constraints.NotNull +import javax.ws.rs.client.Client @JsonTypeName("jersey") class JerseyModule : PrimeModule { + @JsonProperty + var config: Config = Config() + override fun init(env: Environment) { - env.jersey().register(YamlMessageBodyReader::class.java) + val jerseyEnv = env.jersey() + + // Read incoming YAML requests + jerseyEnv.register(YamlMessageBodyReader::class.java) + + // filter to set TraceID in Logging MDC + jerseyEnv.register(TrackRequestsLoggingFilter()) + + // ping resource to check connectivity + jerseyEnv.register(PingResource()) + + val client: Client = JerseyClientBuilder(env) + .using(config.jerseyClientConfiguration) + .using(jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)) + .build(env.name) + + /* OAuth2 with cache. */ + val authenticator = CachingAuthenticator(env.metrics(), + OAuthAuthenticator(client), + config.authenticationCachePolicy) + + jerseyEnv.register(AuthDynamicFeature( + Builder() + .setAuthenticator(authenticator) + .setPrefix("Bearer") + .buildAuthFilter())) + jerseyEnv.register(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) + } +} + +class Config { + + @Valid + @NotNull + @get:JsonProperty("authenticationCachePolicy") + var authenticationCachePolicy: CacheBuilderSpec? = null + private set + + @Valid + @NotNull + @get:JsonProperty("jerseyClient") + val jerseyClientConfiguration = JerseyClientConfiguration() - /* Add filters/interceptors. */ - env.jersey().register(TrackRequestsLoggingFilter()) + @JsonProperty("authenticationCachePolicy") + fun setAuthenticationCachePolicy(spec: String) { + this.authenticationCachePolicy = CacheBuilderSpec.parse(spec) } } \ No newline at end of file diff --git a/jersey/src/main/kotlin/org/ostelco/prime/jersey/PingResource.kt b/jersey/src/main/kotlin/org/ostelco/prime/jersey/PingResource.kt new file mode 100644 index 000000000..2a0ed5b35 --- /dev/null +++ b/jersey/src/main/kotlin/org/ostelco/prime/jersey/PingResource.kt @@ -0,0 +1,12 @@ +package org.ostelco.prime.jersey + +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.core.Response + +@Path("/ping") +class PingResource { + + @GET + fun ping(): Response = Response.ok().build() +} \ No newline at end of file diff --git a/jersey/src/main/kotlin/org/ostelco/prime/jersey/YamlMessageBodyReader.kt b/jersey/src/main/kotlin/org/ostelco/prime/jersey/YamlMessageBodyReader.kt index b36b0d0ea..43bacb440 100644 --- a/jersey/src/main/kotlin/org/ostelco/prime/jersey/YamlMessageBodyReader.kt +++ b/jersey/src/main/kotlin/org/ostelco/prime/jersey/YamlMessageBodyReader.kt @@ -17,6 +17,7 @@ import javax.ws.rs.ext.MessageBodyReader class YamlMessageBodyReader : MessageBodyReader { private val logger by getLogger() + private val mapper = ObjectMapper(YAMLFactory()).registerKotlinModule() override fun isReadable( type: Class<*>, @@ -32,7 +33,6 @@ class YamlMessageBodyReader : MessageBodyReader { inputStream: InputStream): Any { try { - val mapper = ObjectMapper(YAMLFactory()).registerKotlinModule() return mapper.readValue(inputStream, type) } catch (e: Exception) { logger.error("Failed to parse yaml: ${e.message}") diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/GetUserInfoTest.kt b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/AuthTest.kt similarity index 88% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/GetUserInfoTest.kt rename to jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/AuthTest.kt index bb17bc232..c9f64375b 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/GetUserInfoTest.kt +++ b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/AuthTest.kt @@ -1,4 +1,4 @@ -package org.ostelco.prime.client.api.auth +package org.ostelco.prime.jersey.auth import io.dropwizard.client.JerseyClientBuilder import io.dropwizard.testing.ConfigOverride @@ -10,8 +10,8 @@ import org.glassfish.jersey.client.ClientProperties import org.junit.BeforeClass import org.junit.ClassRule import org.junit.Test -import org.ostelco.prime.client.api.auth.helpers.TestApp -import org.ostelco.prime.client.api.util.AccessToken +import org.ostelco.prime.jersey.auth.helpers.AccessToken +import org.ostelco.prime.jersey.auth.helpers.TestApp import javax.ws.rs.client.Client import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response @@ -20,7 +20,7 @@ import javax.ws.rs.core.Response * Tests OAuth2 callback to '.../userinfo" endpoint. * */ -class GetUserInfoTest { +class AuthTest { private val email = "boaty@internet.org" @@ -28,7 +28,7 @@ class GetUserInfoTest { "http://localhost:${RULE.localPort}/userinfo") @Test - fun getProfileNotFound() { + fun getNotFound() { // XXX Race condition makes test fail sometimes. @@ -39,7 +39,7 @@ class GetUserInfoTest { val response = client.target( - "http://localhost:${RULE.localPort}/profile") + "http://localhost:${RULE.localPort}/foo") .request() .property(ClientProperties.CONNECT_TIMEOUT, 30000) .property(ClientProperties.READ_TIMEOUT, 30000) @@ -70,7 +70,7 @@ class GetUserInfoTest { } if (counter == 0) { - fail("Couldn't connect to RULE server") + fail("Couldn't connect to RULE server") } } @@ -87,7 +87,7 @@ class GetUserInfoTest { @BeforeClass @JvmStatic fun setUpClient() { - client = JerseyClientBuilder(RULE.environment).build("test client"); + client = JerseyClientBuilder(RULE.environment).build("test client") } } } diff --git a/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/AccessToken.kt b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/AccessToken.kt new file mode 100644 index 000000000..f25064b1e --- /dev/null +++ b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/AccessToken.kt @@ -0,0 +1,22 @@ +package org.ostelco.prime.jersey.auth.helpers + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm + +object AccessToken { + + private const val key = "secret" + private const val namespace = "https://ostelco.org" + + fun withEmail(email: String, audience: List): String { + + val claims = mapOf("$namespace/email" to email, + "aud" to audience, + "sub" to email) + + return Jwts.builder() + .setClaims(claims) + .signWith(SignatureAlgorithm.HS512, key) + .compact() + } +} diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/TestApp.kt b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/TestApp.kt similarity index 69% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/TestApp.kt rename to jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/TestApp.kt index 196db835a..1bbb560d5 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/TestApp.kt +++ b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/TestApp.kt @@ -1,9 +1,7 @@ -package org.ostelco.prime.client.api.auth.helpers +package org.ostelco.prime.jersey.auth.helpers -import arrow.core.Either import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper -import com.nhaarman.mockito_kotlin.argumentCaptor import io.dropwizard.Application import io.dropwizard.auth.AuthDynamicFeature import io.dropwizard.auth.AuthValueFactoryProvider @@ -14,14 +12,15 @@ import io.dropwizard.configuration.EnvironmentVariableSubstitutor import io.dropwizard.configuration.SubstitutingSourceProvider import io.dropwizard.setup.Bootstrap import io.dropwizard.setup.Environment -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.ostelco.prime.client.api.auth.AccessTokenPrincipal -import org.ostelco.prime.client.api.auth.OAuthAuthenticator -import org.ostelco.prime.client.api.resources.ProfileResource -import org.ostelco.prime.client.api.store.SubscriberDAO -import org.ostelco.prime.apierror.ApiErrorCode -import org.ostelco.prime.apierror.NotFoundError +import org.ostelco.prime.auth.AccessTokenPrincipal +import org.ostelco.prime.auth.OAuthAuthenticator +import org.ostelco.prime.jsonmapper.asJson +import javax.ws.rs.Consumes +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response class TestApp : Application() { @@ -37,15 +36,9 @@ class TestApp : Application() { override fun run(config: TestConfig, env: Environment) { - val DAO = mock(SubscriberDAO::class.java) - - val arg = argumentCaptor() - `when`(DAO.getProfile(arg.capture())) - .thenReturn(Either.left(NotFoundError("No profile found", ApiErrorCode.FAILED_TO_FETCH_PAYMENT_PROFILE))) - /* APIs. */ - env.jersey().register(ProfileResource(DAO)) env.jersey().register(UserInfoResource()) + env.jersey().register(FooResource()) val client = JerseyClientBuilder(env) .using(config.jerseyClientConfiguration) @@ -67,3 +60,15 @@ class TestApp : Application() { env.jersey().register(AuthValueFactoryProvider.Binder(AccessTokenPrincipal::class.java)) } } + +@Path("/foo") +class FooResource { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + fun query(): Response = Response + .status(Response.Status.NOT_FOUND) + .entity(asJson("")) + .build() +} \ No newline at end of file diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/TestConfig.kt b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/TestConfig.kt similarity index 94% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/TestConfig.kt rename to jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/TestConfig.kt index bb96d8ca7..2ce20b396 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/TestConfig.kt +++ b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/TestConfig.kt @@ -1,4 +1,4 @@ -package org.ostelco.prime.client.api.auth.helpers +package org.ostelco.prime.jersey.auth.helpers import com.fasterxml.jackson.annotation.JsonProperty import com.google.common.cache.CacheBuilderSpec diff --git a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/UserInfoResource.kt b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/UserInfoResource.kt similarity index 86% rename from client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/UserInfoResource.kt rename to jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/UserInfoResource.kt index 272421d4b..32a4dc0a5 100644 --- a/client-api/src/test/kotlin/org/ostelco/prime/client/api/auth/helpers/UserInfoResource.kt +++ b/jersey/src/test/kotlin/org/ostelco/prime/jersey/auth/helpers/UserInfoResource.kt @@ -1,4 +1,4 @@ -package org.ostelco.prime.client.api.auth.helpers +package org.ostelco.prime.jersey.auth.helpers import javax.validation.Valid import javax.ws.rs.GET @@ -18,7 +18,7 @@ class UserInfoResource { @GET @Produces("application/json") - fun getuserInfo(@Valid @HeaderParam("Authorization") token: String?): Response { + fun getUserInfo(@Valid @HeaderParam("Authorization") token: String?): Response { return if (token == null) { Response.status(Response.Status.NOT_FOUND) diff --git a/client-api/src/test/resources/test.yaml b/jersey/src/test/resources/test.yaml similarity index 100% rename from client-api/src/test/resources/test.yaml rename to jersey/src/test/resources/test.yaml diff --git a/model/build.gradle b/model/build.gradle index 73d0f5aef..3bce6310b 100644 --- a/model/build.gradle +++ b/model/build.gradle @@ -1,13 +1,14 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" implementation "com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion" + implementation "com.google.cloud:google-cloud-datastore:$googleCloudVersion" // TODO vihang: this dependency is added only for @Exclude annotation for firebase implementation "com.google.firebase:firebase-admin:$firebaseVersion" - implementation "org.slf4j:slf4j-api:1.7.25" + implementation "org.slf4j:slf4j-api:$slf4jVersion" } \ No newline at end of file diff --git a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt index 6e68893fa..e0cb786c3 100644 --- a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt +++ b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt @@ -1,12 +1,18 @@ package org.ostelco.prime.model import com.fasterxml.jackson.annotation.JsonIgnore +import com.google.cloud.datastore.Blob import com.google.firebase.database.Exclude +import java.util.* interface HasId { val id: String } +data class Region( + override val id: String, + val name: String) : HasId + data class Offer( override val id: String, val segments: Collection = emptyList(), @@ -21,22 +27,148 @@ data class ChangeSegment( val targetSegmentId: String, val subscribers: Collection) -data class Subscriber( - val email: String, - val name: String = "", - val address: String = "", - val postCode: String = "", - val city: String = "", - val country: String = "", - private val referralId: String = email) : HasId { +data class Customer( + override val id: String = UUID.randomUUID().toString(), + val nickname: String, + val contactEmail: String, + val analyticsId: String = UUID.randomUUID().toString(), + val referralId: String = UUID.randomUUID().toString()) : HasId + +data class Identity( + override val id: String, + val type: String, + val provider: String) : HasId + +data class RegionDetails( + val region: Region, + val status: CustomerRegionStatus, + val kycStatusMap: Map = emptyMap(), + val simProfiles: Collection = emptyList()) + +enum class CustomerRegionStatus { + PENDING, // eKYC initiated, but not yet approved + APPROVED, // eKYC approved +} + +enum class KycType { + JUMIO, + MY_INFO, + NRIC_FIN, + ADDRESS_AND_PHONE_NUMBER +} + +enum class KycStatus { + PENDING, // eKYC initiated, but not yet approved or rejected + REJECTED, // eKYC rejected + APPROVED // eKYC approved +} + +enum class ScanStatus { + PENDING, // scan results are pending + REJECTED, // scanned Id was rejected + APPROVED // scanned Id was approved +} - constructor(email: String): this(email = email, referralId = email) +data class ScanResult( + val vendorScanReference: String, + val verificationStatus: String, + val time: Long, + val type: String?, + val country: String?, + val firstName: String?, + val lastName: String?, + val dob: String?, + val rejectReason: String?) - fun getReferralId() = email +data class ScanInformation( + val scanId: String, + val countryCode: String, + val status: ScanStatus, + val scanResult: ScanResult? +) : HasId { override val id: String + @Exclude @JsonIgnore - get() = email + get() = scanId +} + +data class VendorScanInformation( + val id: String, // Id of the scan + val scanReference: String, // Jumio transaction reference + val details: String, // JSON string representation of all the information from vendor + val images: Map? // liveness images (JPEG or PNG) if available +) + + +data class ScanMetadata( + val id: String, // Id of the scan + val scanReference: String, // Jumio transaction reference + val countryCode: String, // The country for which the scan was done + val customerId: String, // The owner of the scan + val processedTime: Long // The time when callback was processed. +) + +enum class ScanMetadataEnum(val s: String) { + // Property names for Datastore + ID("id"), + SCAN_REFERENCE("scanReference"), + COUNTRY_CODE("countryCode"), + CUSTOMER_ID("customerId"), + PROCESSED_TIME("processedTime"), + // Type name of the Object + KIND("ScanMetaData") +} + +enum class JumioScanData(val s: String) { + // Property names in POST data from Jumio + JUMIO_SCAN_ID("jumioIdScanReference"), + SCAN_ID("merchantIdScanReference"), + SCAN_STATUS("idScanStatus"), + VERIFICATION_STATUS("verificationStatus"), + CALLBACK_DATE("callbackDate"), + ID_TYPE("idType"), + ID_COUNTRY("idCountry"), + ID_FIRSTNAME("idFirstName"), + ID_LASTNAME("idLastName"), + ID_DOB("idDob"), + SCAN_IMAGE("idScanImage"), + SCAN_IMAGE_FACE("idScanImageFace"), + SCAN_IMAGE_BACKSIDE("idScanImageBackside"), + SCAN_LIVENESS_IMAGES("livenessImages"), + REJECT_REASON("rejectReason"), + IDENTITY_VERIFICATION("identityVerification"), + SIMILARITY("similarity"), + VALIDITY("validity"), + REASON("reason"), + APPROVED_VERIFIED("APPROVED_VERIFIED"), + MATCH("MATCH"), + TRUE("TRUE"), + // Extended values from prime + PRIME_MISSING_IDENTITY_VERIFICATION("PRIME_MISSING_IDENTITY_VERIFICATION"), + PRIME_IDENTITY_VALID_SIMILAR("PRIME_IDENTITY_VALID_SIMILAR"), + PRIME_IDENTITY_VERIFICATION_FAILED("PRIME_IDENTITY_VERIFICATION_FAILED"), + PRIME_MISSING_IDENTITY_REASON("PRIME_MISSING_IDENTITY_REASON") +} + +enum class VendorScanData(val s: String) { + // Property names in VendorScanInformation + ID("scanId"), + DETAILS("scanDetails"), + IMAGE("scanImage"), + IMAGE_TYPE("scanImageType"), + IMAGEBACKSIDE("scanImageBackside"), + IMAGEBACKSIDE_TYPE("scanImageBacksideType"), + IMAGEFACE("scanImageFace"), + IMAGEFACE_TYPE("scanImageFaceType"), + // Name of the datastore type + TYPE_NAME("VendorScanInformation") +} + +enum class FCMStrings(val s: String) { + NOTIFICATION_TITLE("eKYC Status"), + JUMIO_IDENTITY_VERIFIED("Successfully verified the identity"), + JUMIO_IDENTITY_FAILED("Failed to verify the identity") } // TODO vihang: make ApplicationToken data class immutable @@ -54,7 +186,7 @@ data class ApplicationToken( data class Subscription( val msisdn: String, - val alias: String = "") : HasId { + val analyticsId: String = UUID.randomUUID().toString()) : HasId { override val id: String @JsonIgnore @@ -84,11 +216,30 @@ data class ProductClass( override val id: String, val properties: List = listOf()) : HasId +// Note: The 'name' value becomes the name (sku) of the corresponding product in Stripe. +data class Plan( + val name: String, + val price: Price, + val interval: String, + val intervalCount: Long = 1L, + val properties: Map = emptyMap(), + val presentation: Map = emptyMap()) : HasId { + + override val id: String + @JsonIgnore + get() = name +} + +data class RefundRecord( + override val id: String, + val reason: String, // possible values are duplicate, fraudulent, and requested_by_customer + val timestamp: Long) : HasId + data class PurchaseRecord( override val id: String, - @Deprecated("Will be removed in future") val msisdn: String, val product: Product, - val timestamp: Long) : HasId + val timestamp: Long, + val refund: RefundRecord? = null) : HasId data class PurchaseRecordInfo(override val id: String, val subscriberId: String, @@ -103,12 +254,26 @@ data class PurchaseRecordInfo(override val id: String, status) } -data class PseudonymEntity( - val sourceId: String, - val pseudonym: String, - val start: Long, - val end: Long) +data class SimEntry( + val iccId: String, + val status: SimProfileStatus, + val eSimActivationCode: String, + val msisdnList: Collection) + +data class SimProfile( + val iccId: String, + @JvmField val eSimActivationCode: String, + val status: SimProfileStatus, + val alias: String = "") + +enum class SimProfileStatus { + NOT_READY, + AVAILABLE_FOR_DOWNLOAD, + DOWNLOADED, + INSTALLED, + ENABLED, +} -data class ActivePseudonyms( - val current: PseudonymEntity, - val next: PseudonymEntity) +data class Context( + val customer: Customer, + val regions: Collection = emptyList()) \ No newline at end of file diff --git a/myinfo-client/build.gradle b/myinfo-client/build.gradle new file mode 100644 index 000000000..f088e0db5 --- /dev/null +++ b/myinfo-client/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'java-library' + id 'org.hidetake.swagger.generator' version '2.18.1' + id "idea" +} + +// gradle generateSwaggerCode +swaggerSources { + 'java-client' { + inputFile = file("${projectDir}/src/main/swagger/myinfo_swagger_v2.1.1.yaml") + code { + language = 'java' + configFile = file("${projectDir}/config.json") + } + } +} + +compileJava.dependsOn swaggerSources.'java-client'.code +sourceSets.main.java.srcDir "${swaggerSources.'java-client'.code.outputDir}/src/main/java" +sourceSets.main.resources.srcDir "${swaggerSources.'java-client'.code.outputDir}/src/main/resources" + +dependencies { + swaggerCodegen "io.swagger:swagger-codegen-cli:$swaggerCodegenVersion" + + implementation 'javax.annotation:javax.annotation-api:1.3.2' + + implementation 'io.swagger.core.v3:swagger-annotations:2.0.0' + implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.squareup.okhttp:okhttp:2.7.5' + implementation 'com.squareup.okhttp:logging-interceptor:2.7.5' + implementation 'io.gsonfire:gson-fire:1.8.3' + implementation 'org.threeten:threetenbp:1.3.8' + testImplementation 'junit:junit:4.12' +} + +idea { + module { + // generatedSourceDirs + sourceDirs += files("${project.buildDir.path}/swagger-code-java-client/src/main/java") + } +} \ No newline at end of file diff --git a/myinfo-client/config.json b/myinfo-client/config.json new file mode 100644 index 000000000..87ed47aa6 --- /dev/null +++ b/myinfo-client/config.json @@ -0,0 +1,5 @@ +{ + "invokerPackage": "org.ostelco.prime.ekyc.myinfo", + "modelPackage": "org.ostelco.prime.ekyc.myinfo.model", + "apiPackage": "org.ostelco.prime.ekyc.myinfo.api" +} \ No newline at end of file diff --git a/myinfo-client/src/main/swagger/myinfo_swagger_v2.1.1.json b/myinfo-client/src/main/swagger/myinfo_swagger_v2.1.1.json new file mode 100644 index 000000000..f9a760c98 --- /dev/null +++ b/myinfo-client/src/main/swagger/myinfo_swagger_v2.1.1.json @@ -0,0 +1,2251 @@ +{ + "openapi": "3.0.0", + "servers": [ + { + "url": "https://myinfosgstg.api.gov.sg/dev", + "description": "Sandbox" + }, + { + "url": "https://myinfosgstg.api.gov.sg/test", + "description": "Staging" + }, + { + "url": "https://myinfosg.api.gov.sg/", + "description": "Production" + } + ], + "info": { + "version": "2.1.1", + "title": "MyInfo API", + "x-logo": { + "url": "https://s3-ap-southeast-1.amazonaws.com/myinfobiz-apispecs/myinfo-logo.jpg" + }, + "description": "MyInfo REST APIs for retrieving Person data.\n\n**Note - this specification is subject to changes based on evolution of the APIs.**\n# Release Notes\n* **2.1.1 (21 September 2018)**\n - updated error codes and descriptions for `token` and `person` APIs for better clarity when troubleshooting.\n\n* **2.1.0 (01 July 2018)**\n - \"relationship\" data item is deprecated\n - New data items available:\n - childrenbirthrecords\n - marriagecertno\n - countryofmarriage\n - workpassstatus\n - workpassexpirydate\n\n* **2.0.0 (20 April 2018)**\n - Base URL changed from:\n - myinfo.api.gov.sg to myinfosg.api.gov.sg (Production) and myinfosgstg.api.gov.sg (Staging)\n - APIs updated to v2:\n - Staging:\n - /test/v2/authorise\n - /test/v2/token\n - /test/v2/person/{uinfin}/\n - Production:\n - /v2/authorise\n - /v2/token\n - /v2/person/{uinfin}/\n - Updated responses for v2 Person APIs:\n - Response format will be JSON Web Encryption (JWE) instead of JSON Web Signature (JWS).\n\n\n\n## Releases and Compatibility\nThe RESTful API adopts __Semantic Versioning 2.0.0__ for releases, and every new release of the API increments the version numbers in the following format:\n```\n{MAJOR}.{MINOR}.{PATCH}\n```\n\n1. `{MAJOR}` number introduces incompatible API changes with previous `{MAJOR}` number also resets `{MINOR}` to `0`,\n2. `{MINOR}` number introduces new functionalities or information that are backward compatible also resets `{PATCH}` to `0`, and\n3. `{PATCH}` number introduces bug fixes and remains backward compatible.\n\nPre-release or draft versions, when provided, are denoted by appended hypen `-` with a series of separate identifiers `{LABEL}-{VERSION}` following the `{PATCH}` number. Such releases are unstable and may not provide the intended compatibility with the specification in draft status.\n\nServing as notice, the RESTful API in version `2.X.X` are incompatible with version `1.X.X` releases.\n\nDespite backward compatibility in `{MINOR}` or `{PATCH}` releases, API consumers are best to evaluate and determine their implementation does not disrupt use-case requirements.\n\n# Overview\nThe following diagram illustrates how the integration with MyInfo APIs work:\n![Overview](https://www.ndi-api.gov.sg/assets/lib/trusted-data/myinfo/img/myinfo-logical-architecture.png \"MyInfo API Overview\")\n\nAs shown above, your application will be interfacing with our Government API Gateway (APEX) to integrate successfully with MyInfo.\n\n# Environments\n\nThe RESTful APIs are provided in both testing and live environments, and are accessible over the __Internet__ via HTTPS.\n\nConsumers are to ensure firewall clearance on their edge network nodes for connecting to the APIs.\n\nThe convention used by API endpoints' URLs is in the following format:\n\n```\nhttps://{ENV_DOMAIN_NAME}/{VERSION}/{RESOURCE}\n```\n\n* `{ENV_DOMAIN_NAME}` indicates MyInfo's API gateway __Internet__\n domain names - respectively:\n * `myinfosg.api.gov.sg/`, or\n * `myinfosgstg.api.gov.sg/test`, or\n * `myinfosgstg.api.gov.sg/dev`, following\n* `/{VERSION}` indicates the endpoint's release `{MAJOR}` version number path - for this release:\n * `/v2`, and\n* `/{RESOURCE}` indicates the API resource path name.\nAny additional query string parameters are appended as needed.\n\n## Available Environments\n* **Sandbox/Dev**: `https://myinfosgstg.api.gov.sg/dev/`\n* **Staging**: `https://myinfosgstg.api.gov.sg/test/`\n* **Production**: `https://myinfosg.api.gov.sg/`\n\n## Scheduled Downtimes\nThe following are the scheduled downtimes for the various environments:\n\n### Production Environment\n* **CPFB data** - Everyday 5:00am to 5:30am\n* **IRAS data** - Every Wed, 2:00am to 6:00am, Every Sun, 2:00am to 8:30am\n* **Once a month**, Sunday 12:00 am to 8:00 am (date to be advised)\n\n### Sandbox & Staging Environments\n* Every Wednesday 3pm to 12am.\n\n# Security\n## HTTPS Interface\n\nAt time of writing, MyInfo's API gateway supports accessing of APIs via the following interfaces:\n\n* HTTP version 1.1 connection over __TLS__ (Transport Layer Security) version __1.1__ or __1.2__ standards, and ciphersuites:\n * using __AES__ (Advanced Encryption Standard) and __SHA__ (Secure Hash Algorithm),\n * on either __GCM__ (Galois/Counter Mode) or __CBC__ (Cipher Block Chaining) mode.\n* Below is the list of recommended cipher suites that you may use:\n\n * TLS_RSA_WITH_AES_256_GCM_SHA384\n * TLS_RSA_WITH_AES_128_GCM_SHA256\n * TLS_RSA_WITH_AES_256_CBC_SHA256\n * TLS_RSA_WITH_AES_256_CBC_SHA\n * TLS_RSA_WITH_AES_128_CBC_SHA256\n * TLS_RSA_WITH_AES_128_CBC_SHA\n\n> **IMPORTANT:** ensure your server supports **TLS 1.1 or 1.2** and supports a cipher suite in the list above.\n\nAccessing the RESTful APIs using prior versions of TLS and/or unsupported ciphersuites will result in connectivity errors. MyInfo's API gateway does not support 2-way TLS client nor mutual authentication.\n\nAPI HTTP interface features:\n\n1. __JSON__ (JavaScript Object Notation) is the supported data media format and indicated in `Content-Type` header `application/json`, also\n2. `Content-Length` header is omitted by having `Transfer-Encoding` header `chunked` emitted for streaming data, and\n3. __GZip__ (GNU Zip) response compression is supported by opt-in `Accept-Encoding: gzip` and indicated in `Content-Encoding` header `gzip`.\n\n## OAuth2.0\nMyInfo APIs use OAuth2.0 authorisation code flow to perform authentication & authorisation.\n\nThe sequence diagram below illustrates the steps involved in integrating your application with our APIs:\n\n![OAuth](https://www.ndi-api.gov.sg/assets/lib/trusted-data/myinfo/img/myinfo-oauth2-sequence.png)\n\nThe flow consists of 3 APIs:\n1. **Authorise**\n * This will trigger the SingPass login and consent page. Once successful, your application will receive the **authorisation code** via your callback url.\n\n2. **Token**\n * Call this server-to-server API with a valid **authorisation code** to get the **access token**.\n\n3. **Protected Resource (Person)**\n * Call this server-to-server API with a valid **access token** to get the person data.\n\n\n## Application Authentication\n\nAccess to all server-to-server APIs will be authenticated by MyInfo's API gateway.\nPrior to calling the APIs, respective consumers are required to have:\n\n* approval of access, onboarding process for the required API resources will be provisioned, and\n* authentication credentials are then supplied and exchanged.\n\nAuthentication methods provided by MyInfo's API gateway on internet:\n\n* L2 - OAuth 2.0 using `SHA256withRSA` digital signature (see \"**Request Signing**\" section below)\n* Digital signature should be produced using a RSA private key with corresponding public certificate issued by one of the following compatible CAs:\n * digiCert\n * Entrust\n * Comodo\n * VeriSign\n * GlobalSign\n * GeoTrust\n * Thawte\n\n\n\n## Request Signing\n\nAll server-to-server API requests are to be digitally signed, by including the following parameters and values in the `Authorization` header:\n\n```\nApex_L2_Eg realm=\"{realm}\",\napex_l2_eg_app_id=\"{app_id}\",\napex_l2_eg_nonce=\"{random_nonce}\",\napex_l2_eg_signature_method=\"SHA256withRSA\",\napex_l2_eg_signature=\"{base64_url_percent_encoded_signature}\",\napex_l2_eg_timestamp=\"{unix_epoch_in_milliseconds}\",\napex_l2_eg_version=\"1.0\"\n```\n\n*__Note__: Above sample is separated by lines for ease-of-reading, and new-line denotations are to be omitted in the actual request.*\n\n* `{realm}` is the logical name of your application.\n* `{app_id}` is the APP ID credential supplied upon onboarding,\n* `{random_nonce}` is an unique randomly generated text used for replay prevention,\n* `{signature_algorithm}` is the signature algorithm of the authenticating APEX gateway.\n\n * Value of __signature_algorithm__ = `SHA256withRSA`\n\n* `{base64_url_percent_encoded_signature}` is the binary of the generated signature encoded in __Base64__ URL-safe format,\n* `{unix_epoch_in_milliseconds}` is the UNIX epoch time in milliseconds, and\n* `apex_l2_eg_version=\"1.0\"` parameter is optional, and when emitted the value has to be `1.0`.\n\n### Sample Code in `NodeJS`\n```\n // generates the security headers for calling APEX\n function generateAuthorizationHeader(url, params, method, strContentType, authType, appId, keyCertContent, passphrase, realm) {\n // NOTE: need to include the \".e.\" in order for the security authorisation header to work\n url = _.replace(url, \".api.gov.sg\", \".e.api.gov.sg\");\n\n if (authType == \"L2\") {\n return generateSHA256withRSAHeader(url, params, method, strContentType, appId, keyCertContent, passphrase, realm);\n } else {\n return \"\";\n }\n };\n\n // Signing Your Requests\n function generateSHA256withRSAHeader(url, params, method, strContentType, appId, keyCertContent, keyCertPassphrase, realm) {\n var nonceValue = nonce();\n var timestamp = (new Date).getTime();\n\n // A) Construct the Authorisation Token Parameters\n var defaultApexHeaders = {\n \"apex_l2_eg_app_id\": appId, // App ID assigned to your application\n \"apex_l2_eg_nonce\": nonceValue, // secure random number\n \"apex_l2_eg_signature_method\": \"SHA256withRSA\",\n \"apex_l2_eg_timestamp\": timestamp, // Unix epoch time\n \"apex_l2_eg_version\": \"1.0\"\n };\n\n // B) Forming the Base String\n // Base String is a representation of the entire request (ensures message integrity)\n\n // i) Normalize request parameters\n var baseParams = sortJSON(_.merge(defaultApexHeaders, params));\n\n var baseParamsStr = qs.stringify(baseParams);\n baseParamsStr = qs.unescape(baseParamsStr); // url safe\n\n // ii) construct request URL ---> url is passed in to this function\n // NOTE: need to include the \".e.\" in order for the security authorisation header to work\n //myinfosgstg.api.gov.sg -> myinfosgstg.e.api.gov.sg\n url = _.replace(url, \".api.gov.sg\", \".e.api.gov.sg\");\n\n // iii) concatenate request elements (HTTP method + url + base string parameters)\n var baseString = method.toUpperCase() + \"&\" + url + \"&\" + baseParamsStr;\n\n // C) Signing Base String to get Digital Signature\n var signWith = {\n key: fs.readFileSync(keyCertContent, 'utf8')\n }; // Provides private key\n\n // Load pem file containing the x509 cert & private key & sign the base string with it to produce the Digital Signature\n var signature = crypto.createSign('RSA-SHA256')\n .update(baseString)\n .sign(signWith, 'base64');\n\n // D) Assembling the Authorization Header\n var strApexHeader = \"apex_l2_eg realm=\\\"\" + realm + // Defaults to 1st part of incoming request hostname\n \"\\\",apex_l2_eg_timestamp=\\\"\" + timestamp +\n \"\\\",apex_l2_eg_nonce=\\\"\" + nonceValue +\n \"\\\",apex_l2_eg_app_id=\\\"\" + appId +\n \"\\\",apex_l2_eg_signature_method=\\\"SHA256withRSA\\\"\" +\n \",apex_l2_eg_version=\\\"1.0\\\"\" +\n \",apex_l2_eg_signature=\\\"\" + signature +\n \"\\\"\";\n\n return strApexHeader;\n };\n\n\n```\n## Token Validation\nAccess Tokens are in JWT format. This JWT complies to the standard 'JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants' (https://tools.ietf.org/html/rfc7523). You will need to verify the token with our public cert (provided during application onboarding).\n### Sample Code in `NodeJS`\n```\n // Sample Code for Verifying & Decoding JWS or JWT\n function verifyJWS(jws, publicCert) {\n // verify token\n // ignore notbefore check because it gives errors sometimes if the call is too fast.\n try {\n var decoded = jwt.verify(jws, fs.readFileSync(publicCert, 'utf8'), {\n algorithms: ['RS256'],\n ignoreNotBefore: true\n });\n return decoded;\n }\n catch(error) {\n throw(\"Error with verifying and decoding JWS\");\n }\n }\n\n```\n\n## Payload Encryption (Person)\nThe response payload for the **Person** API (for staging and production environments) is encrypted in [**JWE (JSON Web Encryption) Compact Serialization**](https://tools.ietf.org/html/rfc7516) format.\n* Encryption is done using your application's public key that you provided during onboarding. Decryption of the payload should be using the private key of that key-pair.\n* Current encryption algorithms used:\n * **RSA-OAEP** (for content key wrapping)\n * **AES256GCM** (for content encrytion)\n\n\n### Sample Code in `NodeJS`\n```\n // Sample Code for decrypting JWE\n // Decrypt JWE using private key\n function decryptJWE(header, encryptedKey, iv, cipherText, tag, privateKey) {\n\n return new Promise((resolve, reject) => {\n\n var keystore = jose.JWK.createKeyStore();\n\n var data = {\n \"type\": \"compact\",\n \"ciphertext\": cipherText,\n \"protected\": header,\n \"encrypted_key\": encryptedKey,\n \"tag\": tag,\n \"iv\": iv,\n \"header\": JSON.parse(jose.util.base64url.decode(header).toString())\n };\n keystore.add(fs.readFileSync(privateKey, 'utf8'), \"pem\")\n .then(function(jweKey) {\n // {result} is a jose.JWK.Key\n jose.JWE.createDecrypt(jweKey)\n .decrypt(data)\n .then(function(result) {\n resolve(JSON.parse(result.payload.toString()));\n })\n .catch(function(error) {\n reject(error);\n });\n });\n\n })\n .catch (error => {\n throw \"Error with decrypting JWE\";\n })\n }\n\n```\n# Error Handling\n\nThe RESTful APIs used HTTP specification standard status codes to indicate the success or failure of each request. Except gateway errors, the response content will be in the following JSON format:\n\n```\n{\n \"code\": \"integer (int32)\",\n \"message\": \"string\"\n}\n```\n> **Refer to the individual API definitions for the error codes you might encounter for each API.**\n\n\n# Support\nPlease refer to the [MyInfo Developer and Partner Portal](https://www.ndi-api.gov.sg/) for the following supporting materials where relevant:\n- [Code reference tables](https://www.ndi-api.gov.sg/assets/lib/trusted-data/myinfo/downloads/myinfo-api-code-tables.xlsx)\n- [Test accounts for sandbox/staging environments](https://www.ndi-api.gov.sg/library/trusted-data/myinfo/resources-personas)\n- [Reference user journey templates](https://www.ndi-api.gov.sg/library/trusted-data/myinfo/implementation-reference-journey)\n\n\nFor technical queries, contact [support@myinfo.gov.sg](mailto:support@myinfo.gov.sg).\n\nFor business queries, contact [partner@myinfo.gov.sg](mailto:partner@myinfo.gov.sg).\n\n# Authentication\n\n" + }, + "tags": [ + { + "name": "MyInfo", + "description": "RESTful API Definitions" + } + ], + "x-tagGroups": [ + { + "name": "API Definitions", + "tags": [ + "MyInfo" + ] + } + ], + "paths": { + "/v2/person-sample/{uinfin}/": { + "get": { + "tags": [ + "MyInfo" + ], + "summary": "Person-Sample", + "description": "Retrieves a sample Person data from MyInfo based on UIN/FIN. This API does not require authorisation token.\n\n**Note:** Null value indicates that an attribute is unavailable.", + "servers": [ + { + "url": "https://myinfosgstg.api.gov.sg/dev", + "description": "Sandbox" + } + ], + "operationId": "getpersonsample", + "parameters": [ + { + "in": "path", + "name": "uinfin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "attributes", + "description": "Comma separated list of attributes requested.\n\nPossible attributes are listed in the Person object definition below.", + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "string", + "minimum": 1 + } + } + } + ], + "x-code-samples": [ + { + "lang": "Shell", + "source": "curl https://myinfosgstg.api.gov.sg/dev/v2/person-sample/S9203266C/" + } + ], + "responses": { + "200": { + "description": "OK.\n\n**Note:**\n- Response will be a JSON object.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + } + } + } + }, + "/v2/authorise": { + "get": { + "tags": [ + "MyInfo" + ], + "summary": "Authorise", + "description": "This API triggers SingPass login and obtain consent for the user to retrieve user's data from MyInfo.\nOnce the user has authenticated and consented, an authorisation code (authcode) will be returned together with the state for verification via the callback URL defined.\nThe authcode can then be used to retrieve an access token via the Token API.\n\n**Note:** This API is public and should be implemented as a link or button on your online webpage.", + "operationId": "getauthorise", + "parameters": [ + { + "in": "query", + "name": "authmode", + "description": "Mode of authentication used to obtain user consent. Default is \"SINGPASS\".", + "required": false, + "schema": { + "type": "string", + "enum": [ + "SINGPASS", + "MOBILEID" + ], + "default": "SINGPASS" + } + }, + { + "in": "query", + "name": "purpose", + "description": "State the purpose for requesting the data. This will be shown to the user for his consent.", + "allowEmptyValue": false, + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "response_type", + "description": "Response type for authorisation code flow - must be \"code\".", + "allowEmptyValue": false, + "required": false, + "schema": { + "type": "string", + "default": "code" + } + }, + { + "in": "query", + "name": "attributes", + "description": "Comma separated list of attributes requested. Possible attributes are listed in the scopes of the OAuth2 Security Schema above.", + "required": true, + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "string", + "minimum": 1 + } + } + }, + { + "in": "query", + "name": "state", + "description": "Identifier to reconcile query and response. This will be sent back to you via the callback URL. Use a unique system generated number for each and every call.", + "allowEmptyValue": false, + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "redirect_uri", + "description": "Your callback URL for MyInfo to return with the authorisation code.", + "allowEmptyValue": false, + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "client_id", + "description": "Unique ID for your application.", + "allowEmptyValue": false, + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Service will redirect all responses to 'redirect_uri' with additional parameters added as response results. Expected parameters include:\n- **code**: this is the authorisation code you will use when calling the token endpoint\n- **state**: this should be the same state passed in your initial URL.\n- **error**: if there are any errors encountered, the error code will be given in this parameter. If user did not give consent, error value will be 'access_denied', and refer to error_description parameter for the reason.\n - **'404'** - User not found or not registered with MyInfo.\n - **'428'** - Profile Incomplete. User registered for MyInfo less than 1 working day.\n - **'500'** - Unknown or other server side errors.\n - **'503'** - MyInfo under maintenance. Error description will also be given in error_description parameter.\n- **error_description**: if error is 'access_denied' i.e. user did not give consent, the description will be 'Resource Owner did not authorize the request'.\n\n\n**Note:** If user closes the browser window prematurely, there will be no callback to the 'redirect_uri'. Therefore it is important for you to check the 'state' to verify that the transaction is the same." + } + }, + "x-code-samples": [ + { + "lang": "JavaScript", + "source": "function callAuthoriseApi() {\n var authoriseUrl = authApiUrl + \"?client_id=\" + clientId +\n \"&attributes=\" + attributes +\n \"&purpose=\" + purpose +\n \"&state=\" + state +\n \"&redirect_uri=\" + redirectUrl;\n\n window.location = authoriseUrl;\n}\n" + } + ] + } + }, + "/v2/token": { + "post": { + "tags": [ + "MyInfo" + ], + "summary": "Token", + "description": "This API generates an access token when presented with a valid authcode obtained from the Authorise API. This token can then be used to request for the user's data that were consented.", + "operationId": "gettoken", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "description": "Add authorization token constructed containing the RSA digital signature of the base string. Refer to https://www.ndi-api.gov.sg/library/trusted-data/myinfo/tutorial3 on how this token should be generated.\n\n**Note:** This header is not required when calling Sandbox API.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK. returning a JSON object which contains the authorisation access token (JWT) that will be used to retrieve the data from MyInfo.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthTokenResponse" + } + } + } + }, + "400": { + "description": "AuthCode error - missing, invalid, expired, revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenError" + } + } + } + }, + "401": { + "description": "Possible scenarios:\n - No security header given\n - Invalid App ID used. Digital service is not registered with MyInfo\n - The timestamp of server is not synchronised. Check timestamp of server\n - The value of the nonce in the authorisation header was deemed to be repeated. Check that the nonce is not re-used\n - Ensure Apex header to be 'apex_l2_eg' and not 'apex', resulting in 'No security header' error message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenError" + } + } + } + }, + "404": { + "description": "Possible scenarios:\n - Wrong Token API URL used. Refer to tutorial for the correct Token API URL(staging/production)\n - Same authcode in the body is being used. We do not allow same authcode being used in multiple calls. Ensure that authcode is not repeated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenError" + } + } + } + }, + "500": { + "description": "Unexpected error. Error message will be in the response body.\n\nPossible scenarios:\n - Error message: Signature length incorrect. Issue is due to error in digital signature. - Verify your signature by using our signature verifier tool. - Ensure correct key is used to sign the base string.\n - Ensure there is a slash (/) after UINFIN in the request URL. E.g. `https://myinfosg.api.gov.sg/v2/person/S9812381D/`\n - Attributes should be ordered in lexicographical byte value\n - Ensure API URL in the base string include '.e' - `https://myinfosg.e.api.gov.sg`\n - Ensure attributes in base string are separate by comma(,), and not %2C\n - Signature generated is incorrect. Ensure the correct private key used to produce the signature, and base string is UTF-8 encoded.\n - Ensure UIN/FIN is specified in the Person API URL. E.g. `https://myinfosg.e.api.gov.sg/v2/person/`\n - Ensure the base string contains the following:\n 1. HTTP GET method(in uppcase)\n 2. API (e.g. https://..)\n 3. These 6 APEX parameters:\n * apex_l2_eg_app_id\n * apex_l2_eg_nonce\n * apex_l2_eg_signature\n * apex_l2_eg_signature_method\n * apex_l2_eg_timestamp\n * apex_l2_eg_version" + } + }, + "requestBody": { + "$ref": "#/components/requestBodies/gettoken" + }, + "x-code-samples": [ + { + "lang": "NodeJS", + "source": "// function to prepare request for TOKEN API\nfunction createTokenRequest(code) {\n var cacheCtl = \"no-cache\";\n var contentType = \"application/x-www-form-urlencoded\";\n var method = \"POST\";\n var request = null;\n\n // preparing the request with header and parameters\n // assemble params for Token API\n var strParams = \"grant_type=authorization_code\" +\n \"&code=\" + code +\n \"&redirect_uri=\" + _redirectUrl +\n \"&client_id=\" + _clientId +\n \"&client_secret=\" + _clientSecret;\n var params = querystring.parse(strParams);\n\n\n // assemble headers for Token API\n var strHeaders = \"Content-Type=\" + contentType + \"&Cache-Control=\" + cacheCtl;\n var headers = querystring.parse(strHeaders);\n\n // Sign request and add Authorization Headers\n var authHeaders = generateAuthorizationHeader(\n _tokenApiUrl,\n params,\n method,\n contentType,\n _authLevel,\n _clientId,\n _privateKeyContent,\n _clientSecret,\n _realm\n );\n\n if (!_.isEmpty(authHeaders)) {\n _.set(headers, \"Authorization\", authHeaders);\n }\n\n var request = restClient.post(_tokenApiUrl);\n\n // Set headers\n if (!_.isUndefined(headers) && !_.isEmpty(headers))\n request.set(headers);\n\n // Set Params\n if (!_.isUndefined(params) && !_.isEmpty(params))\n request.send(params);\n\n return request;\n}\n" + } + ] + } + }, + "/v2/person/{uinfin}/": { + "get": { + "tags": [ + "MyInfo" + ], + "summary": "Person", + "description": "This API returns user's data from MyInfo when presented with a valid access token obtained from the Token API.\n\n**Note:** Null value indicates that an attribute is unavailable.", + "operationId": "getperson", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "description": "Add authorization token constructed containing the RSA digital signature of the base string. Refer to https://www.ndi-api.gov.sg/library/trusted-data/myinfo/tutorial3 on how this token should be generated. Also include the access token (JWT) from /token API in your header prefixed with 'Bearer'.\n\n**Note:** Only the Bearer token is required when calling Sandbox API.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "txnNo", + "description": "Transaction ID from requesting digital services for cross referencing.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "uinfin", + "description": "UIN/FIN of user", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "attributes", + "required": true, + "description": "Comma separated list of attributes requested. Possible attributes are listed in the scopes of the OAuth2 Security Schema above.", + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "string", + "minimum": 1 + } + } + }, + { + "in": "query", + "name": "client_id", + "description": "Unique ID for your application.", + "allowEmptyValue": false, + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK.\n\n**Note:**\n- Response Content-Encoding will be 'gzip'.\n- Response Content-Type will be 'application/jose', which is a JSON object conforming to the JWE standard (https://tools.ietf.org/html/rfc7516).\n- Your application should decrypt the signature with your application's private key.\n- After decrypting the signature, your application will be able to extract the payload in JSON format.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "401": { + "description": "Unauthorized. This could be due to the scenarios below:\n - No security header given\n - Invalid App ID used. Digital service is not registered with MyInfo\n - The timestamp of server is not synchronised. Check timestamp of server\n - The value of the nonce in the authorisation header was deemed to be repeated. Check that the nonce is not re-used\n - Ensure Apex header to be 'apex_l2_eg' and not 'apex', resulting in 'No security header' error message\n - The requested UIN/FIN does not match the UIN/FIN of the person who logged in\n - Request contains attributes not allowable for the digital service.\n\nDetails will be given in the error object returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Possible scenarios:\n\n - Forbidden. Digital service is not registered with MyInfo.\n - Requested attributes does not match the attributes consented by person.\n This happens if the list of attributes in your request are different from the attributes specified when calling the token API.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found. UIN/FIN does not exist in MyInfo.\n\n**MESSAGE:** 'UIN/FIN is invalid.'\n\nPossible scenarios:\n - Wrong Person API URL used. Refer to tutorial for the correct Person API URL(staging/production)\n - UIN/FIN has a SingPass account, but does not have a MyInfo profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Error with request parameters or headers. Error details will be provided in the response body.\nPossible scenarios:\n - Error message: Signature length incorrect. Issue is due to error in digital signature. - Verify your signature by using our signature verifier tool. - Ensure correct key is used to sign the base string.\n - Ensure there is a slash (/) after UINFIN in the request URL. E.g. `https://myinfosg.api.gov.sg/v2/person/S9812381D/`\n - Attributes should be ordered in lexicographical byte value\n - Ensure API URL in the base string include '.e' - `https://myinfosg.e.api.gov.sg`\n - Ensure attributes in base string are separate by comma(,), and not %2C\n - Signature generated is incorrect. Ensure the correct private key used to produce the signature, and base string is UTF-8 encoded.\n - Ensure UIN/FIN is specified in the Person API URL. E.g. `https://myinfosg.e.api.gov.sg/v2/person/`\n - Ensure the base string contains the following:\n 1. HTTP GET method(in uppcase)\n 2. API (e.g. https://..)\n 3. These 6 APEX parameters:\n * apex_l2_eg_app_id\n * apex_l2_eg_nonce\n * apex_l2_eg_signature\n * apex_l2_eg_signature_method\n * apex_l2_eg_timestamp\n * apex_l2_eg_version" + } + }, + "x-code-samples": [ + { + "lang": "NodeJS", + "source": "// function to prepare request for PERSON API\nfunction createPersonRequest(uinfin, validToken) {\n var url = _personApiUrl + \"/\" + uinfin + \"/\";\n var cacheCtl = \"no-cache\";\n var method = \"GET\";\n var request = null;\n // assemble params for Person API\n var strParams = \"client_id=\" + _clientId +\n \"&attributes=\" + _attributes;\n var params = querystring.parse(strParams);\n\n // assemble headers for Person API\n var strHeaders = \"Cache-Control=\" + cacheCtl;\n var headers = querystring.parse(strHeaders);\n var authHeaders;\n\n // Sign request and add Authorization Headers\n authHeaders = generateAuthorizationHeader(\n url,\n params,\n method,\n \"\", // no content type needed for GET\n _authLevel,\n _clientId,\n _privateKeyContent,\n _clientSecret,\n _realm\n );\n\n if (!_.isEmpty(authHeaders)) {\n _.set(headers, \"Authorization\", authHeaders + \",Bearer \" + validToken);\n }\n else {\n // NOTE: include access token in Authorization header as \"Bearer \" (with space behind)\n _.set(headers, \"Authorization\", \"Bearer \" + validToken);\n }\n\n // invoke token API\n var request = restClient.get(url);\n\n // Set headers\n if (!_.isUndefined(headers) && !_.isEmpty(headers))\n request.set(headers);\n\n // Set Params\n if (!_.isUndefined(params) && !_.isEmpty(params))\n request.query(params);\n\n return request;\n}\n" + } + ], + "security": [] + } + } + }, + "components": { + "requestBodies": { + "gettoken": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "code": { + "description": "The authcode given by the authorise API.", + "type": "string" + }, + "grant_type": { + "description": "Grant type for getting token (default \"authorization_code\")", + "type": "string", + "default": "authorization_code" + }, + "client_secret": { + "description": "Secret key given to your application during onboarding.", + "type": "string" + }, + "client_id": { + "description": "Unique ID for your application.", + "type": "string" + }, + "redirect_uri": { + "description": "Your callback URL for MyInfo to validate.", + "type": "string" + } + }, + "required": [ + "code", + "client_secret", + "client_id", + "redirect_uri" + ] + } + } + } + } + }, + "securitySchemes": { + "oauth2": { + "description": "The following are the available OAuth2 scopes for MyInfo APIs\n", + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "/authorise", + "tokenUrl": "/token", + "scopes": { + "name": "Full Name", + "hanyupinyinname": "Han Yu Pin Yin name", + "aliasname": "Alias name", + "hanyupinyinaliasname": "Han Yu Pin Yin Alias name", + "marriedname": "Married name", + "sex": "Sex", + "race": "Race", + "secondaryrace": "Secondary Race", + "dialect": "Dialect", + "nationality": "Nationality", + "dob": "Date of Birth", + "birthcountry": "Country of Birth", + "residentialstatus": "Residential Status", + "passportnumber": "Passport Number", + "passportexpirydate": "Passport Expiry Date", + "regadd": "Registered Address", + "mailadd": "Mailing Address", + "billadd": "Billing Address", + "housingtype": "Housing Type", + "hdbtype": "HDB Type", + "ownerprivate": "Ownership of Private Property Status", + "email": "Email Address", + "homeno": "Home Contact Number", + "mobileno": "Mobile Number", + "marital": "Marital Status", + "marriagecertno": "Certificate number of the latest marriage", + "countryofmarriage": "Country of the latest marriage", + "marriagedate": "Latest Marriage Date", + "divorcedate": "Last Divorce Date", + "childrenbirthrecords": "Details of Children Birth Records", + "relationships": "Details of Relationships", + "edulevel": "Highest Education Level", + "gradyear": "Year of Graduation", + "schoolname": "Name of School", + "occupation": "Occupation", + "employment": "Employer's Name", + "workpassstatus": "Work pass status of a FIN holder", + "workpassexpirydate": "Work pass expiry of a foreigner", + "householdincome": "Household Income", + "assessableincome": "Latest Assessable Income", + "assessyear": "Year of Assessment (for Assessable Income)", + "cpfcontributions": "Employer CPF Contributions", + "cpfbalances": "CPF Balances", + "vehno": "Vehicle Number" + } + } + } + } + }, + "schemas": { + "AuthTokenResponse": { + "description": "Authentication Token Response JSON", + "type": "object", + "properties": { + "token_type": { + "type": "string", + "description": "Type of token (Bearer)", + "default": "Bearer" + }, + "id_token": { + "type": "string", + "description": "Id token of the person who logged in. This is in the form of JWT (JSON web token).\n\n**Note:** This is not used." + }, + "access_token": { + "$ref": "#/components/schemas/JWTAccessToken" + } + } + }, + "JWTAccessToken": { + "title": "JWTAccessToken", + "description": "Access token to be used in the subsequent 'person' endpoint call. This is in the form of JWT (JSON web token). Include this in your header as 'Bearer' when invoking the 'person' API. This JWT complies to the standard 'JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants' (https://tools.ietf.org/html/rfc7523).\n\n**Note:** This token is returned in the form of a compact serialized string. Decode and verify the signature before use.", + "type": "object", + "properties": { + "tokenName": { + "type": "string", + "description": "Name of Token. This will be 'access_token'.", + "default": "access_token" + }, + "sub": { + "type": "string", + "description": "(subject) This is the 'uinfin' of the user who logged in." + }, + "scope": { + "type": "array", + "description": "Scopes allowed. This is the list attributes user consented to share.", + "items": { + "type": "string" + } + }, + "nbf": { + "type": "string", + "description": "(not before) - the time before which the token MUST NOT be accepted for processing" + }, + "iss": { + "type": "string", + "description": "(issuer) Issuer of the JWT." + }, + "expires_in": { + "type": "string", + "description": "The remaining lifetime of the access token." + }, + "iat": { + "type": "string", + "description": "(issued at) Time which JWT was issued at." + }, + "exp": { + "type": "string", + "description": "(expiration time) time which JWT will expire" + }, + "realm": { + "type": "string", + "description": "Realm for OAuth process. Default \"/myinfo\"" + }, + "aud": { + "type": "string", + "description": "Audience for JWT. Default \"myinfo\"." + }, + "jti": { + "type": "string", + "description": "(JWT ID) unique identifier for the JWT token." + }, + "token_type": { + "type": "string", + "description": "type of token, which is \"Bearer\".", + "default": "Bearer" + }, + "authGrantId": { + "type": "string", + "description": "Internal system id for auth grant. Not used." + }, + "auditTrackingId": { + "type": "string", + "description": "Internal Id for audit tracking. not used." + } + } + }, + "Person": { + "type": "object", + "properties": { + "name": { + "type": "object", + "title": "Name", + "description": "Full Name of the Person.", + "properties": { + "value": { + "type": "string", + "maxLength": 66, + "description": "value of the field, should be displayed as it is." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "hanyupinyinname": { + "type": "object", + "title": "HanYuPinYin", + "description": "Han Yu Pin Yin name of the Person.\n\n*Presentation Logic - If there is a value to `hanyupinyinname` (i.e. not empty), then `hanyupinyinname` should be displayed in a new line below `name`, and formatted with round brackets i.e. \"(`hanyupinyinname`)\"'*", + "properties": { + "value": { + "type": "string", + "maxLength": 66, + "description": "value of the field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "aliasname": { + "type": "object", + "title": "Alias", + "description": "Alias name of the Person.\n\n*Presentation Logic - If there is a value to `aliasname` (i.e. not empty), then `aliasname` should be displayed in a new line below `hanyupinyinname`, and prefixed with the ''@'' symbol i.e. \"@`aliasname`\".'*", + "properties": { + "value": { + "type": "string", + "maxLength": 66, + "description": "value of the field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "hanyupinyinaliasname": { + "type": "object", + "title": "HanYuPinYinAlias", + "description": "Han Yu Pin Yin Alias name of the Person.\n\n*Presentation Logic - If there is a value to `hanyupinyinaliasname` (i.e. not empty), then `hanyupinyinaliasname` should be displayed in a new line below `aliasname`, and prefixed with the ''@'' symbol i.e. \"@`hanyupinyinaliasname`\".*", + "properties": { + "value": { + "type": "string", + "maxLength": 66, + "description": "value of the field" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "marriedname": { + "type": "object", + "title": "MarriedName", + "description": "Married name of the Person.\n\n*Presentation Logic - If there is a value to `marriedname` (i.e. not empty), then `marriedname` should be displayed in a new line below `hanyupinyinaliasname`.*", + "properties": { + "value": { + "type": "string", + "maxLength": 66, + "description": "value of the field, should be displayed as it is." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "sex": { + "type": "object", + "title": "Sex", + "description": "Sex of Person.\n* 'F' - Female\n* 'M' - Male\n* 'U' - Unknown", + "properties": { + "value": { + "type": "string", + "enum": [ + "F", + "M", + "U" + ], + "maxLength": 1, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "race": { + "type": "object", + "title": "Race", + "description": "Race of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "value": { + "type": "string", + "pattern": "[a-zA-Z]{2}", + "maxLength": 2, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "secondaryrace": { + "type": "object", + "title": "SecondaryRace", + "description": "Secondary Race of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "value": { + "type": "string", + "pattern": "[a-zA-Z]{2}", + "maxLength": 2, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "dialect": { + "type": "object", + "title": "Dialect", + "description": "Dialect of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "value": { + "type": "string", + "pattern": "[a-zA-Z]{2}", + "maxLength": 2, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "nationality": { + "type": "object", + "title": "Nationality", + "description": "Nationality of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "value": { + "type": "string", + "pattern": "[a-zA-Z]{2}", + "maxLength": 2, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "dob": { + "type": "object", + "title": "DOB", + "description": "Date of Birth of Person.", + "properties": { + "value": { + "type": "string", + "format": "date", + "description": "Value of data field. See \"full-date\" in http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "birthcountry": { + "type": "object", + "title": "BirthCountry", + "description": "Country of Birth of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "value": { + "type": "string", + "pattern": "[a-zA-Z]{2}", + "maxLength": 2, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "residentialstatus": { + "type": "object", + "title": "ResidentialStatus", + "description": "Residential Status of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "code": { + "type": "string", + "pattern": "[a-zA-Z]{1}", + "maxLength": 1, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "passportnumber": { + "type": "object", + "title": "PassportNumber", + "description": "Passport Number of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "code": { + "type": "string", + "maxLength": 25, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "passportexpirydate": { + "type": "object", + "title": "PassportExpiryDate", + "description": "Passport Expiry Date of Person.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values.", + "properties": { + "code": { + "type": "string", + "format": "date", + "description": "Value of data field. See \"full-date\" in http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "regadd": { + "type": "object", + "title": "RegAdd", + "description": "Registered Address of Person", + "allOf": [ + { + "$ref": "#/components/schemas/AddressLocal" + }, + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "mailadd": { + "type": "object", + "title": "MailAdd", + "description": "Mailing Address of Person", + "allOf": [ + { + "$ref": "#/components/schemas/AddressLocal" + }, + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "billadd": { + "type": "object", + "title": "BillAdd", + "description": "Billing Address of Person", + "allOf": [ + { + "$ref": "#/components/schemas/AddressLocal" + }, + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "housingtype": { + "type": "object", + "title": "HousingType", + "description": "Housing Type of Person (non-HDB only).\n\n**Note:** This value will be null if housing type is HDB;\n\nRefer to `hdbtype` for detailed HDB type.\n\n* '121' - DETACHED HOUSE\n* '122' - SEMI-DETACHED HOUSE\n* '123' - TERRACE HOUSE\n* '131' - CONDOMINIUM\n* '132' - EXECUTIVE CONDOMINIUM\n* '139' - APARTMENT", + "properties": { + "value": { + "type": "string", + "description": "value of data field", + "enum": [ + 121, + 122, + 123, + 131, + 132, + 139 + ] + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "hdbtype": { + "type": "object", + "title": "HDBType", + "description": "HDB Type of Person (HDB only).\n\n**Note:** This value will be null if housing type is not HDB.\n\n* '111' - 1-ROOM FLAT (HDB)\n* '112' - 2-ROOM FLAT (HDB)\n* '113' - 3-ROOM FLAT (HDB)\n* '114' - 4-ROOM FLAT (HDB)\n* '115' - 5-ROOM FLAT (HDB)\n* '116' - EXECUTIVE FLAT (HDB)\n* '118' - STUDIO APARTMENT (HDB)", + "properties": { + "value": { + "type": "string", + "description": "Value of data field", + "enum": [ + 111, + 112, + 113, + 114, + 115, + 116, + 118 + ] + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "ownerprivate": { + "type": "object", + "title": "OwnerPrivate", + "description": "Ownership of Private Property Status of Person (based on IRAS information).\n\n* true\n* false\n* null (data not available)", + "properties": { + "value": { + "type": "boolean", + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "email": { + "type": "object", + "title": "Email", + "description": "Email Address of Person.", + "properties": { + "value": { + "type": "string", + "maxLength": 320, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "homeno": { + "type": "object", + "title": "HomeNo", + "description": "Home Contact Number of Person.", + "allOf": [ + { + "$ref": "#/components/schemas/PhoneNumLocal" + }, + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "mobileno": { + "type": "object", + "title": "MobileNo", + "description": "Mobile Number of Person.", + "allOf": [ + { + "$ref": "#/components/schemas/PhoneNumLocal" + }, + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "marital": { + "type": "object", + "title": "Marital", + "description": "Marital Status of Person.\n\n* '1' - SINGLE\n* '2' - MARRIED\n* '3' - WIDOWED\n* '5' - DIVORCED\n\n**Note:** This field must be made **editable** on your digital service form even though `source` is '1' (Government Verified).", + "properties": { + "value": { + "type": "string", + "description": "value of data field", + "enum": [ + 1, + 2, + 3, + 5 + ] + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "marriagecertno": { + "type": "object", + "title": "MarriageCertNo", + "description": "Certificate number of the latest marriage.\n\n\n**Note:** This field must be made **editable** on your digital service form even though `source` is '1' (Government Verified).", + "properties": { + "value": { + "type": "string", + "maxLength": 15, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "countryofmarriage": { + "type": "object", + "title": "CountryOfMarriage", + "description": "Country of the latest marriage.\n\n\n**Note:** This field must be made **editable** on your digital service form even though `source` is '1' (Government Verified).", + "properties": { + "value": { + "type": "string", + "maxLength": 2, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "marriagedate": { + "type": "object", + "title": "MarriageDate", + "description": "Latest Marriage Date of Person.\n\n\n**Note:** This field must be made **editable** on your digital service form even though `source` is '1' (Government Verified).\n", + "properties": { + "value": { + "type": "string", + "format": "date", + "description": "Value of data field.\n\nSee \"full-date\" in http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "divorcedate": { + "type": "object", + "title": "DivorceDate", + "description": "Last Divorce Date of Person.\n\n\n**Note:** This field must be made **editable** on your digital service form even though `source` is '1' (Government Verified).\n", + "properties": { + "value": { + "type": "string", + "format": "date", + "description": "Value of data field.\n\nSee \"full-date\" in http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "childrenbirthrecords": { + "type": "object", + "title": "ChildrenBirthRecords", + "description": "This refers to only local registered birth records(based on ICA’s electronic Birth Certificate Records from 1985 onwards).\n\nThis includes adoption of locally registered child.\n\nFor child below 21, the child’s Birth Cert No, Name, Sex, Race, Dialect, Date of Birth and Time of Birth will be shown.\n\nFor child above 21, only the child’s Birth Cert Number will be shown.", + "properties": { + "birthrecords": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChildBirthRecord" + } + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "relationships": { + "type": "array", + "deprecated": true, + "title": "Relationships", + "items": { + "$ref": "#/components/schemas/Relationship" + }, + "description": "Details of Relationships of Person" + }, + "edulevel": { + "type": "object", + "title": "EduLevel", + "description": "Highest Education Level of Person.\n\n* '0' - NO FORMAL QUALIFICATION / PRE-PRIMARY / LOWER PRIMARY\n* '1' - PRIMARY\n* '2' - LOWER SECONDARY\n* '3' - SECONDARY\n* '4' - POST-SECONDARY (NON-TERTIARY): GENERAL & VOCATION\n* '5' - POLYTECHNIC DIPLOMA\n* '6' - PROFESSIONAL QUALIFICATION AND OTHER DIPLOMA\n* '7' - BACHELOR'S OR EQUIVALENT\n* '8' - POSTGRADUATE DIPLOMA / CERTIFICATE (EXCLUDING MASTER'S AND DOCTORATE)\n* '9' - MASTER'S AND DOCTORATE OR EQUIVALENT\n* 'N' - MODULAR CERTIFICATION (NON-AWARD COURSES / NON-FULL QUALIFICATIONS)", + "properties": { + "value": { + "type": "string", + "description": "value of data field", + "enum": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "N" + ] + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "gradyear": { + "type": "object", + "title": "GradYear", + "description": "Year of Graduation of Person. Format: YYYY", + "properties": { + "value": { + "type": "string", + "pattern": "[0-9]{4}", + "maxLength": 4, + "description": "value of data field" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "schoolname": { + "type": "object", + "title": "SchoolName", + "description": "Name of School of Person. ", + "properties": { + "type": { + "type": "string", + "maxLength": 10, + "description": "Code value of school name.\n\nRefer to the [Code reference tables](#section/Support) for list of possible school names.\n\n**Note:** Code and desc are mutually exclusive." + }, + "desc": { + "type": "string", + "maxLength": 100, + "description": "Free text value of school name.\n**Note:** Code and desc are mutually exclusive." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "occupation": { + "type": "object", + "title": "Occupation", + "description": "Occupation of Person. ", + "properties": { + "value": { + "type": "string", + "maxLength": 5, + "pattern": "[0-9]{5}", + "description": "Code value of occupation based on SSOC 2015.\n\nFor full list, refer to SSOC 2015 at https://www.singstat.gov.sg/standards/standards-and-classifications/ssoc.\n\nFor FIN holders, blank will be returned.\n\n**Note:** Value and desc are mutually exclusive." + }, + "desc": { + "type": "string", + "maxLength": 100, + "description": "Free text value of occupation.\n\n**Note:** Value and desc are mutually exclusive." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "employment": { + "type": "object", + "title": "Employment", + "description": "Employer's Name of Person.", + "properties": { + "value": { + "type": "string", + "maxLength": 124, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "workpassstatus": { + "type": "object", + "title": "WorkPassStatus", + "description": "Work pass status of a FIN holder.
**Note:** Only applies to a foreigner with a valid work pass. \n*\tLive\n*\tApproved\n", + "properties": { + "value": { + "type": "string", + "enum": [ + "Live", + "Approved" + ], + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "workpassexpirydate": { + "type": "object", + "title": "WorkPassExpiryDate", + "description": "Work pass expiry of a foreigner.
**Note:** Only applies to a foreigner with a valid work pass.", + "properties": { + "value": { + "type": "string", + "format": "date", + "description": "Value of data field. See \"full-date\" in http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "householdincome": { + "type": "object", + "title": "HouseholdIncome", + "description": "Household Income of Person in SGD.", + "properties": { + "high": { + "type": "number", + "format": "double", + "description": "upper bound of the range of household income bracket" + }, + "low": { + "type": "number", + "format": "double", + "description": "lower bound of the range of household income bracket" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "assessableincome": { + "type": "object", + "title": "AssessableIncome", + "description": "Latest Assessable Income of Person in SGD.", + "properties": { + "value": { + "type": "number", + "format": "double", + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "assessyear": { + "type": "object", + "title": "AssessYear", + "description": "Year of Assessment (for Assessable Income). Format: YYYY", + "properties": { + "value": { + "type": "string", + "pattern": "[0-9]{4}", + "maxLength": 4, + "description": "value of data field" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "cpfcontributions": { + "type": "object", + "title": "CPFContributions", + "description": "Employer CPF Contributions of Person in SGD. Does not include any non-employer contributions. Maximum past 14 months' of contributions.", + "properties": { + "contributions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CPFContribution" + } + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "cpfbalances": { + "type": "object", + "title": "CPFBalances", + "description": "CPF Balances of Person in SGD.", + "properties": { + "ma": { + "type": "number", + "format": "double", + "description": "Amount Balance in CPF-MA" + }, + "oa": { + "type": "number", + "format": "double", + "description": "Amount Balance in CPF-OA" + }, + "sa": { + "type": "number", + "format": "double", + "description": "Amount Balance in CPF-SA" + }, + "ra": { + "type": "number", + "format": "double", + "description": "Amount Balance in CPF-RA" + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "vehno": { + "type": "object", + "title": "VehNo", + "description": "Vehicle Number of Person.", + "properties": { + "value": { + "type": "string", + "maxLength": 12, + "description": "Value of data field." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + } + }, + "example": { + "name": { + "lastupdated": "2015-06-01", + "source": "1", + "classification": "C", + "value": "TAN XIAO HUI" + }, + "hanyupinyinname": { + "lastupdated": "2015-06-01", + "source": "1", + "classification": "C", + "value": "CHEN XIAO HUI" + }, + "aliasname": { + "lastupdated": "2015-06-01", + "source": "1", + "classification": "C", + "value": "TRICIA TAN XIAO HUI" + }, + "hanyupinyinaliasname": { + "lastupdated": "2015-06-01", + "source": "1", + "classification": "C", + "value": "" + }, + "marriedname": { + "lastupdated": "2015-06-01", + "source": "1", + "classification": "C", + "value": "" + }, + "sex": { + "lastupdated": "2016-03-11", + "source": "1", + "classification": "C", + "value": "F" + }, + "race": { + "lastupdated": "2016-03-11", + "source": "1", + "classification": "C", + "value": "CN" + }, + "secondaryrace": { + "lastupdated": "2017-08-25", + "source": "1", + "classification": "C", + "value": "EU" + }, + "dialect": { + "lastupdated": "2016-03-11", + "source": "1", + "classification": "C", + "value": "SG" + }, + "nationality": { + "lastupdated": "2016-03-11", + "source": "1", + "classification": "C", + "value": "SG" + }, + "dob": { + "lastupdated": "2016-03-11", + "source": "1", + "classification": "C", + "value": "1958-05-17" + }, + "birthcountry": { + "lastupdated": "2016-03-11", + "source": "1", + "classification": "C", + "value": "SG" + }, + "residentialstatus": { + "lastupdated": "2017-08-25", + "source": "1", + "classification": "C", + "value": "C" + }, + "passportnumber": { + "lastupdated": "2017-08-25", + "source": "1", + "classification": "C", + "value": "E35463874W" + }, + "passportexpirydate": { + "lastupdated": "2017-08-25", + "source": "1", + "classification": "C", + "value": "2020-01-01" + }, + "regadd": { + "country": "SG", + "unit": "128", + "street": "BEDOK NORTH AVENUE 1", + "lastupdated": "2016-03-11", + "block": "548", + "source": "1", + "postal": "460548", + "classification": "C", + "floor": "09", + "building": "" + }, + "mailadd": { + "country": "SG", + "unit": "128", + "street": "BEDOK NORTH AVENUE 1", + "lastupdated": "2016-03-11", + "block": "548", + "source": "2", + "postal": "460548", + "classification": "C", + "floor": "09", + "building": "" + }, + "billadd": { + "country": "SG", + "unit": "", + "street": "", + "lastupdated": "", + "block": "", + "source": "", + "postal": "", + "classification": "", + "floor": "", + "building": "" + }, + "housingtype": { + "lastupdated": "2015-12-23", + "source": "1", + "classification": "C", + "value": "" + }, + "hdbtype": { + "lastupdated": "2015-12-23", + "source": "1", + "classification": "C", + "value": "111" + }, + "ownerprivate": { + "lastupdated": "2015-12-23", + "source": "1", + "classification": "C", + "value": "N" + }, + "email": { + "lastupdated": "2017-12-13", + "source": "4", + "classification": "C", + "value": "test@gmail.com" + }, + "homeno": { + "code": "65", + "prefix": "+", + "lastupdated": "2017-11-20", + "source": "2", + "classification": "C", + "nbr": "66132665" + }, + "mobileno": { + "code": "65", + "prefix": "+", + "lastupdated": "2017-12-13", + "source": "4", + "classification": "C", + "nbr": "97324992" + }, + "marital": { + "lastupdated": "2017-03-29", + "source": "1", + "classification": "C", + "value": "1" + }, + "marriagecertno": { + "lastupdated": "2018-03-02", + "source": "1", + "classification": "C", + "value": "123456789012345" + }, + "countryofmarriage": { + "lastupdated": "2018-03-02", + "source": "1", + "classification": "C", + "value": "SG" + }, + "marriagedate": { + "lastupdated": "", + "source": "1", + "classification": "C", + "value": "" + }, + "divorcedate": { + "lastupdated": "", + "source": "1", + "classification": "C", + "value": "" + }, + "childrenbirthrecords": [ + { + "dialect": "HK", + "race": "CN", + "tob": "0901", + "sex": "F", + "source": "1", + "classification": "C", + "birthcertno": "S5562882C", + "hanyupinyinname": "Cheng Pei Ni", + "hanyupinyinaliasname": "", + "marriedname": "", + "aliasname": "", + "dob": "2011-09-10", + "name": "Jo Tan Pei Ni", + "lastupdated": "2018-05-16", + "secondaryrace": "" + }, + { + "dialect": "HK", + "race": "CN", + "tob": "2021", + "sex": "F", + "source": "1", + "classification": "C", + "birthcertno": "S8816582I", + "hanyupinyinname": "Cheng Wei Ling", + "hanyupinyinaliasname": "", + "marriedname": "", + "aliasname": "", + "dob": "2015-07-18", + "name": "Joyce Tan Wei Ling", + "lastupdated": "2018-05-16", + "secondaryrace": "" + }, + { + "dialect": "HK", + "race": "CN", + "tob": "0901", + "sex": "F", + "source": "1", + "classification": "C", + "birthcertno": "T0202564C", + "hanyupinyinname": "Cheng Shu Hui", + "hanyupinyinaliasname": "", + "marriedname": "", + "aliasname": "", + "dob": "2012-09-10", + "name": "Joycelyn Tan Shu Hui", + "lastupdated": "2018-05-16", + "secondaryrace": "" + } + ], + "relationships": [ + { + "passportno": "", + "name": "TAN AH MUI", + "lastupdated": "2017-10-11", + "source": "2", + "classification": "C", + "type": "REL201", + "idno": "S9999999C" + }, + { + "passportno": "", + "name": "TAN CHIN SOON", + "lastupdated": "2017-10-11", + "source": "2", + "classification": "C", + "type": "REL202", + "idno": "S9999998E" + } + ], + "edulevel": { + "lastupdated": "2017-10-11", + "source": "2", + "classification": "C", + "value": "3" + }, + "gradyear": { + "lastupdated": "2017-10-11", + "source": "2", + "classification": "C", + "value": "1978" + }, + "schoolname": { + "lastupdated": "2017-10-11", + "source": "2", + "classification": "C", + "value": "T07GS3011J", + "desc": "SIGLAP SECONDARY SCHOOL" + }, + "occupation": { + "lastupdated": "2017-10-11", + "source": "2", + "classification": "C", + "value": "53201", + "desc": "HEALTHCARE ASSISTANT" + }, + "employment": { + "lastupdated": "2017-10-11", + "source": "2", + "classification": "C", + "value": "ALPHA" + }, + "workpassstatus": { + "lastupdated": "2018-03-02", + "source": "1", + "classification": "C", + "value": "Live" + }, + "workpassexpirydate": { + "lastupdated": "2018-03-02", + "source": "1", + "classification": "C", + "value": "2018-12-31" + }, + "householdincome": { + "high": "5999", + "low": "5000", + "lastupdated": "2017-10-24", + "source": "2", + "classification": "C" + }, + "assessableincome": { + "lastupdated": "2015-12-23", + "source": "1", + "classification": "C", + "value": "1456789.00" + }, + "assessyear": { + "lastupdated": "2015-12-23", + "source": "1", + "classification": "C", + "value": "2015" + }, + "cpfcontributions": { + "cpfcontribution": [ + { + "date": "2016-12-01", + "amount": "500.00", + "month": "2016-11", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2016-12-12", + "amount": "500.00", + "month": "2016-12", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2016-12-21", + "amount": "500.00", + "month": "2016-12", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-01-01", + "amount": "500.00", + "month": "2016-12", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-01-12", + "amount": "500.00", + "month": "2017-01", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-01-21", + "amount": "500.00", + "month": "2017-01", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-02-01", + "amount": "500.00", + "month": "2017-01", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-02-12", + "amount": "500.00", + "month": "2017-02", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-02-21", + "amount": "500.00", + "month": "2017-02", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-03-01", + "amount": "500.00", + "month": "2017-02", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-03-12", + "amount": "500.00", + "month": "2017-03", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-03-21", + "amount": "500.00", + "month": "2017-03", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-04-01", + "amount": "500.00", + "month": "2017-03", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-04-12", + "amount": "500.00", + "month": "2017-04", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-04-21", + "amount": "500.00", + "month": "2017-04", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-05-01", + "amount": "500.00", + "month": "2017-04", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-05-12", + "amount": "500.00", + "month": "2017-05", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-05-21", + "amount": "500.00", + "month": "2017-05", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-06-01", + "amount": "500.00", + "month": "2017-05", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-06-12", + "amount": "500.00", + "month": "2017-06", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-06-21", + "amount": "500.00", + "month": "2017-06", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-07-01", + "amount": "500.00", + "month": "2017-06", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-07-12", + "amount": "500.00", + "month": "2017-07", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-07-21", + "amount": "500.00", + "month": "2017-07", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-08-01", + "amount": "500.00", + "month": "2017-07", + "employer": "Crystal Horse Invest Pte Ltd" + }, + { + "date": "2017-08-12", + "amount": "750.00", + "month": "2017-08", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-08-21", + "amount": "750.00", + "month": "2017-08", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-09-01", + "amount": "750.00", + "month": "2017-08", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-09-12", + "amount": "750.00", + "month": "2017-09", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-09-21", + "amount": "750.00", + "month": "2017-09", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-10-01", + "amount": "750.00", + "month": "2017-09", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-10-12", + "amount": "750.00", + "month": "2017-10", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-10-21", + "amount": "750.00", + "month": "2017-10", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-11-01", + "amount": "750.00", + "month": "2017-10", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-11-12", + "amount": "750.00", + "month": "2017-11", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-11-21", + "amount": "750.00", + "month": "2017-11", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-12-01", + "amount": "750.00", + "month": "2017-11", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-12-12", + "amount": "750.00", + "month": "2017-12", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2017-12-21", + "amount": "750.00", + "month": "2017-12", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2018-01-01", + "amount": "750.00", + "month": "2017-12", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2018-01-12", + "amount": "750.00", + "month": "2018-01", + "employer": "Delta Marine Consultants PL" + }, + { + "date": "2018-01-21", + "amount": "750.00", + "month": "2018-01", + "employer": "Delta Marine Consultants PL" + } + ], + "lastupdated": "2015-12-23", + "source": "1", + "classification": "C" + }, + "cpfbalances": { + "oa": "1581.48", + "ma": "11470.70", + "lastupdated": "2015-12-23", + "source": "1", + "classification": "C", + "sa": "21967.09" + }, + "vehno": { + "lastupdated": "", + "source": "2", + "classification": "C", + "value": "" + } + } + }, + "DataFieldProperties": { + "type": "object", + "properties": { + "classification": { + "type": "string", + "maxLength": 1, + "enum": [ + "C" + ], + "default": "C", + "description": "Data classification of data field. Default 'C' - Confidential." + }, + "source": { + "type": "string", + "maxLength": 1, + "enum": [ + "1", + "2", + "3", + "4" + ], + "description": "Source of data.\n\n* '1' - Government-verified\n* '2' - User provided\n* '3' - Field is Not Applicable to Person\n* '4' - Verified by SingPass\n\n**Note:** All Government-verified fields must be **non-editable** on your digital service form (some exceptions apply - see individual field descriptions)." + }, + "lastupdated": { + "type": "string", + "format": "date", + "description": "Last updated date of data field. See \"full-date\" in http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14" + } + } + }, + "AddressLocal": { + "type": "object", + "properties": { + "block": { + "type": "string", + "maxLength": 10, + "description": "Block of Address" + }, + "building": { + "type": "string", + "maxLength": 32, + "description": "Building of Address" + }, + "floor": { + "type": "string", + "maxLength": 3, + "description": "Floor of Address" + }, + "unit": { + "type": "string", + "maxLength": 5, + "description": "Unit of Address" + }, + "street": { + "type": "string", + "maxLength": 32, + "description": "Street of Address" + }, + "postal": { + "type": "string", + "minLength": 6, + "maxLength": 6, + "description": "Postal Code of Address" + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "default": "SG", + "description": "Country of Address. For AddressLocal this will always be \"SG\"." + } + } + }, + "CPFContribution": { + "type": "object", + "description": "CPF contribution", + "properties": { + "employer": { + "type": "string", + "maxLength": 80, + "description": "Employer who paid the Contribution." + }, + "date": { + "type": "string", + "format": "date", + "description": "Date of Contribution Paid. See \"full-date\" in http://xml2rfc.ietf.org/public/rfc/htm" + }, + "month": { + "type": "string", + "maxLength": 7, + "description": "Month for which CPF Contribution was paid. Format: YYYY-MM" + }, + "amount": { + "type": "number", + "format": "double", + "description": "Amount of contribution in SGD" + } + } + }, + "Relationship": { + "type": "object", + "properties": { + "type": { + "type": "string", + "maxLength": 6, + "description": "Type of Relationship.\n* REL101 - HUSBAND\n* REL102 - WIFE\n* REL201 - MOTHER\n* REL202 - FATHER\n* REL401 - SON\n* REL402 - DAUGHTER\n* REL601 - BROTHER\n* REL602 - SISTER\n", + "enum": [ + "REL101", + "REL102", + "REL201", + "REL202", + "REL401", + "REL402", + "REL601", + "REL602" + ] + }, + "name": { + "type": "string", + "maxLength": 66, + "description": "Name of family member." + }, + "idno": { + "type": "string", + "maxLength": 9, + "description": "ID Number (NRIC/FIN) of family member.
**Note:** 'idno' and 'passportno' are mutually exclusive." + }, + "passportno": { + "type": "string", + "maxLength": 9, + "description": "Passport Number of family member.
**Note:** 'idno' and 'passportno' are mutually exclusive." + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataFieldProperties" + } + ] + }, + "ChildBirthRecord": { + "type": "object", + "properties": { + "birthcertno": { + "type": "string", + "maxLength": 15, + "description": "Birth certificate number" + }, + "name": { + "type": "string", + "maxLength": 66, + "description": "Full Name of child" + }, + "hanyupinyinname": { + "type": "string", + "maxLength": 66, + "description": "Han Yu Pin Yin name of child.\n\n*Presentation Logic - If there is a value to `hanyupinyinname` (i.e. not empty), then `hanyupinyinname` should be displayed in a new line below `name`, and formatted with round brackets i.e. \\\"(`hanyupinyinname`)\\\".*" + }, + "aliasname": { + "type": "string", + "maxLength": 66, + "description": "Alias name of child.\n\n*Presentation Logic - If there is a value to `aliasname` (i.e. not empty), then `aliasname` should be displayed in a new line below `hanyupinyinname`, and prefixed with the ''@'' symbol i.e. \\\"@`aliasname`\\\".*" + }, + "hanyupinyinaliasname": { + "type": "string", + "maxLength": 66, + "description": "Han Yu Pin Yin Alias name of child.\n\n*Presentation Logic - If there is a value to `hanyupinyinaliasname` (i.e. not empty), then `hanyupinyinaliasname` should be displayed in a new line below `aliasname`, and prefixed with the ''@'' symbol i.e. \\\"@`hanyupinyinaliasname`\\\".*" + }, + "marriedname": { + "type": "string", + "maxLength": 66, + "title": "MarriedName", + "description": "Married name of child.\n\n*Presentation Logic - If there is a value to `marriedname` (i.e. not empty), then `marriedname` should be displayed in a new line below `hanyupinyinaliasname`.*" + }, + "sex": { + "type": "string", + "maxLength": 1, + "enum": [ + "F", + "M", + "U" + ], + "description": "Sex of child.\n* F - Female\n* M - Male\n* U - Unknown" + }, + "race": { + "type": "string", + "maxLength": 2, + "pattern": "[a-zA-Z]{2}", + "description": "Race of child.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values." + }, + "secondaryrace": { + "type": "string", + "maxLength": 2, + "pattern": "[a-zA-Z]{2}", + "description": "Secondary Race of child.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values." + }, + "dialect": { + "type": "string", + "maxLength": 2, + "pattern": "[a-zA-Z]{2}", + "description": "Dialect of child.\n\nRefer to the [Code reference tables](#section/Support) for list of possible values." + }, + "dob": { + "type": "string", + "format": "date", + "description": "Date of Birth of child.\n\nSee \"full-date\" in http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14" + }, + "tob": { + "type": "string", + "maxLength": 4, + "description": "Time of Birth of child.\n\nFormat: HHMM" + } + } + }, + "PhoneNumLocal": { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "default": "+", + "maxLength": 1, + "description": "Prefix of Phone Number. Defaults to '+'. If phone number is blank, prefix will be returned as blank." + }, + "code": { + "type": "string", + "default": "065", + "maxLength": 3, + "description": "Area Code of Phone Number. Default to '065'. If phone number is blank, code will be returned as blank." + }, + "nbr": { + "type": "string", + "maxLength": 12, + "description": "Phone Number." + } + } + }, + "TokenError": { + "type": "object", + "properties": { + "error_description": { + "type": "string" + }, + "error": { + "type": "string" + } + } + }, + "Error": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "fields": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/myinfo-client/src/main/swagger/myinfo_swagger_v2.1.1.yaml b/myinfo-client/src/main/swagger/myinfo_swagger_v2.1.1.yaml new file mode 100644 index 000000000..08d2c8dcf --- /dev/null +++ b/myinfo-client/src/main/swagger/myinfo_swagger_v2.1.1.yaml @@ -0,0 +1,2723 @@ +openapi: 3.0.0 +servers: + - url: 'https://myinfosgstg.api.gov.sg/dev' + description: Sandbox + - url: 'https://myinfosgstg.api.gov.sg/test' + description: Staging + - url: 'https://myinfosg.api.gov.sg/' + description: Production +info: + version: 2.1.1 + title: MyInfo API + x-logo: + url: 'https://s3-ap-southeast-1.amazonaws.com/myinfobiz-apispecs/myinfo-logo.jpg' + description: >- + MyInfo REST APIs for retrieving Person data. + + + **Note - this specification is subject to changes based on evolution of the + APIs.** + + # Release Notes + + * **2.1.1 (21 September 2018)** + - updated error codes and descriptions for `token` and `person` APIs for better clarity when troubleshooting. + + * **2.1.0 (01 July 2018)** + - "relationship" data item is deprecated + - New data items available: + - childrenbirthrecords + - marriagecertno + - countryofmarriage + - workpassstatus + - workpassexpirydate + + * **2.0.0 (20 April 2018)** + - Base URL changed from: + - myinfo.api.gov.sg to myinfosg.api.gov.sg (Production) and myinfosgstg.api.gov.sg (Staging) + - APIs updated to v2: + - Staging: + - /test/v2/authorise + - /test/v2/token + - /test/v2/person/{uinfin}/ + - Production: + - /v2/authorise + - /v2/token + - /v2/person/{uinfin}/ + - Updated responses for v2 Person APIs: + - Response format will be JSON Web Encryption (JWE) instead of JSON Web Signature (JWS). + + + + ## Releases and Compatibility + + The RESTful API adopts __Semantic Versioning 2.0.0__ for releases, and every + new release of the API increments the version numbers in the following + format: + + ``` + + {MAJOR}.{MINOR}.{PATCH} + + ``` + + + 1. `{MAJOR}` number introduces incompatible API changes with previous + `{MAJOR}` number also resets `{MINOR}` to `0`, + + 2. `{MINOR}` number introduces new functionalities or information that are + backward compatible also resets `{PATCH}` to `0`, and + + 3. `{PATCH}` number introduces bug fixes and remains backward compatible. + + + Pre-release or draft versions, when provided, are denoted by appended hypen + `-` with a series of separate identifiers `{LABEL}-{VERSION}` following the + `{PATCH}` number. Such releases are unstable and may not provide the + intended compatibility with the specification in draft status. + + + Serving as notice, the RESTful API in version `2.X.X` are incompatible with + version `1.X.X` releases. + + + Despite backward compatibility in `{MINOR}` or `{PATCH}` releases, API + consumers are best to evaluate and determine their implementation does not + disrupt use-case requirements. + + + # Overview + + The following diagram illustrates how the integration with MyInfo APIs work: + + ![Overview](https://www.ndi-api.gov.sg/assets/lib/trusted-data/myinfo/img/myinfo-logical-architecture.png + "MyInfo API Overview") + + + As shown above, your application will be interfacing with our Government API + Gateway (APEX) to integrate successfully with MyInfo. + + + # Environments + + + The RESTful APIs are provided in both testing and live environments, and are + accessible over the __Internet__ via HTTPS. + + + Consumers are to ensure firewall clearance on their edge network nodes for + connecting to the APIs. + + + The convention used by API endpoints' URLs is in the following format: + + + ``` + + https://{ENV_DOMAIN_NAME}/{VERSION}/{RESOURCE} + + ``` + + + * `{ENV_DOMAIN_NAME}` indicates MyInfo's API gateway __Internet__ + domain names - respectively: + * `myinfosg.api.gov.sg/`, or + * `myinfosgstg.api.gov.sg/test`, or + * `myinfosgstg.api.gov.sg/dev`, following + * `/{VERSION}` indicates the endpoint's release `{MAJOR}` version number + path - for this release: + * `/v2`, and + * `/{RESOURCE}` indicates the API resource path name. + + Any additional query string parameters are appended as needed. + + + ## Available Environments + + * **Sandbox/Dev**: `https://myinfosgstg.api.gov.sg/dev/` + + * **Staging**: `https://myinfosgstg.api.gov.sg/test/` + + * **Production**: `https://myinfosg.api.gov.sg/` + + + ## Scheduled Downtimes + + The following are the scheduled downtimes for the various environments: + + + ### Production Environment + + * **CPFB data** - Everyday 5:00am to 5:30am + + * **IRAS data** - Every Wed, 2:00am to 6:00am, Every Sun, 2:00am to 8:30am + + * **Once a month**, Sunday 12:00 am to 8:00 am (date to be advised) + + + ### Sandbox & Staging Environments + + * Every Wednesday 3pm to 12am. + + + # Security + + ## HTTPS Interface + + + At time of writing, MyInfo's API gateway supports accessing of APIs via the + following interfaces: + + + * HTTP version 1.1 connection over __TLS__ (Transport Layer Security) + version __1.1__ or __1.2__ standards, and ciphersuites: + * using __AES__ (Advanced Encryption Standard) and __SHA__ (Secure Hash Algorithm), + * on either __GCM__ (Galois/Counter Mode) or __CBC__ (Cipher Block Chaining) mode. + * Below is the list of recommended cipher suites that you may use: + + * TLS_RSA_WITH_AES_256_GCM_SHA384 + * TLS_RSA_WITH_AES_128_GCM_SHA256 + * TLS_RSA_WITH_AES_256_CBC_SHA256 + * TLS_RSA_WITH_AES_256_CBC_SHA + * TLS_RSA_WITH_AES_128_CBC_SHA256 + * TLS_RSA_WITH_AES_128_CBC_SHA + + > **IMPORTANT:** ensure your server supports **TLS 1.1 or 1.2** and supports + a cipher suite in the list above. + + + Accessing the RESTful APIs using prior versions of TLS and/or unsupported + ciphersuites will result in connectivity errors. MyInfo's API gateway does + not support 2-way TLS client nor mutual authentication. + + + API HTTP interface features: + + + 1. __JSON__ (JavaScript Object Notation) is the supported data media format + and indicated in `Content-Type` header `application/json`, also + + 2. `Content-Length` header is omitted by having `Transfer-Encoding` header + `chunked` emitted for streaming data, and + + 3. __GZip__ (GNU Zip) response compression is supported by opt-in + `Accept-Encoding: gzip` and indicated in `Content-Encoding` header `gzip`. + + + ## OAuth2.0 + + MyInfo APIs use OAuth2.0 authorisation code flow to perform authentication & + authorisation. + + + The sequence diagram below illustrates the steps involved in integrating + your application with our APIs: + + + ![OAuth](https://www.ndi-api.gov.sg/assets/lib/trusted-data/myinfo/img/myinfo-oauth2-sequence.png) + + + The flow consists of 3 APIs: + + 1. **Authorise** + * This will trigger the SingPass login and consent page. Once successful, your application will receive the **authorisation code** via your callback url. + + 2. **Token** + * Call this server-to-server API with a valid **authorisation code** to get the **access token**. + + 3. **Protected Resource (Person)** + * Call this server-to-server API with a valid **access token** to get the person data. + + + ## Application Authentication + + + Access to all server-to-server APIs will be authenticated by MyInfo's API + gateway. + + Prior to calling the APIs, respective consumers are required to have: + + + * approval of access, onboarding process for the required API resources will + be provisioned, and + + * authentication credentials are then supplied and exchanged. + + + Authentication methods provided by MyInfo's API gateway on internet: + + + * L2 - OAuth 2.0 using `SHA256withRSA` digital signature (see "**Request + Signing**" section below) + + * Digital signature should be produced using a RSA private key with + corresponding public certificate issued by one of the following compatible + CAs: + * digiCert + * Entrust + * Comodo + * VeriSign + * GlobalSign + * GeoTrust + * Thawte + + + + ## Request Signing + + + All server-to-server API requests are to be digitally signed, by including + the following parameters and values in the `Authorization` header: + + + ``` + + Apex_L2_Eg realm="{realm}", + + apex_l2_eg_app_id="{app_id}", + + apex_l2_eg_nonce="{random_nonce}", + + apex_l2_eg_signature_method="SHA256withRSA", + + apex_l2_eg_signature="{base64_url_percent_encoded_signature}", + + apex_l2_eg_timestamp="{unix_epoch_in_milliseconds}", + + apex_l2_eg_version="1.0" + + ``` + + + *__Note__: Above sample is separated by lines for ease-of-reading, and + new-line denotations are to be omitted in the actual request.* + + + * `{realm}` is the logical name of your application. + + * `{app_id}` is the APP ID credential supplied upon onboarding, + + * `{random_nonce}` is an unique randomly generated text used for replay + prevention, + + * `{signature_algorithm}` is the signature algorithm of the authenticating + APEX gateway. + + * Value of __signature_algorithm__ = `SHA256withRSA` + + * `{base64_url_percent_encoded_signature}` is the binary of the generated + signature encoded in __Base64__ URL-safe format, + + * `{unix_epoch_in_milliseconds}` is the UNIX epoch time in milliseconds, and + + * `apex_l2_eg_version="1.0"` parameter is optional, and when emitted the + value has to be `1.0`. + + + ### Sample Code in `NodeJS` + + ``` + // generates the security headers for calling APEX + function generateAuthorizationHeader(url, params, method, strContentType, authType, appId, keyCertContent, passphrase, realm) { + // NOTE: need to include the ".e." in order for the security authorisation header to work + url = _.replace(url, ".api.gov.sg", ".e.api.gov.sg"); + + if (authType == "L2") { + return generateSHA256withRSAHeader(url, params, method, strContentType, appId, keyCertContent, passphrase, realm); + } else { + return ""; + } + }; + + // Signing Your Requests + function generateSHA256withRSAHeader(url, params, method, strContentType, appId, keyCertContent, keyCertPassphrase, realm) { + var nonceValue = nonce(); + var timestamp = (new Date).getTime(); + + // A) Construct the Authorisation Token Parameters + var defaultApexHeaders = { + "apex_l2_eg_app_id": appId, // App ID assigned to your application + "apex_l2_eg_nonce": nonceValue, // secure random number + "apex_l2_eg_signature_method": "SHA256withRSA", + "apex_l2_eg_timestamp": timestamp, // Unix epoch time + "apex_l2_eg_version": "1.0" + }; + + // B) Forming the Base String + // Base String is a representation of the entire request (ensures message integrity) + + // i) Normalize request parameters + var baseParams = sortJSON(_.merge(defaultApexHeaders, params)); + + var baseParamsStr = qs.stringify(baseParams); + baseParamsStr = qs.unescape(baseParamsStr); // url safe + + // ii) construct request URL ---> url is passed in to this function + // NOTE: need to include the ".e." in order for the security authorisation header to work + //myinfosgstg.api.gov.sg -> myinfosgstg.e.api.gov.sg + url = _.replace(url, ".api.gov.sg", ".e.api.gov.sg"); + + // iii) concatenate request elements (HTTP method + url + base string parameters) + var baseString = method.toUpperCase() + "&" + url + "&" + baseParamsStr; + + // C) Signing Base String to get Digital Signature + var signWith = { + key: fs.readFileSync(keyCertContent, 'utf8') + }; // Provides private key + + // Load pem file containing the x509 cert & private key & sign the base string with it to produce the Digital Signature + var signature = crypto.createSign('RSA-SHA256') + .update(baseString) + .sign(signWith, 'base64'); + + // D) Assembling the Authorization Header + var strApexHeader = "apex_l2_eg realm=\"" + realm + // Defaults to 1st part of incoming request hostname + "\",apex_l2_eg_timestamp=\"" + timestamp + + "\",apex_l2_eg_nonce=\"" + nonceValue + + "\",apex_l2_eg_app_id=\"" + appId + + "\",apex_l2_eg_signature_method=\"SHA256withRSA\"" + + ",apex_l2_eg_version=\"1.0\"" + + ",apex_l2_eg_signature=\"" + signature + + "\""; + + return strApexHeader; + }; + + + ``` + + ## Token Validation + + Access Tokens are in JWT format. This JWT complies to the standard 'JSON Web + Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization + Grants' (https://tools.ietf.org/html/rfc7523). You will need to verify the + token with our public cert (provided during application onboarding). + + ### Sample Code in `NodeJS` + + ``` + // Sample Code for Verifying & Decoding JWS or JWT + function verifyJWS(jws, publicCert) { + // verify token + // ignore notbefore check because it gives errors sometimes if the call is too fast. + try { + var decoded = jwt.verify(jws, fs.readFileSync(publicCert, 'utf8'), { + algorithms: ['RS256'], + ignoreNotBefore: true + }); + return decoded; + } + catch(error) { + throw("Error with verifying and decoding JWS"); + } + } + + ``` + + + ## Payload Encryption (Person) + + The response payload for the **Person** API (for staging and production + environments) is encrypted in [**JWE (JSON Web Encryption) Compact + Serialization**](https://tools.ietf.org/html/rfc7516) format. + + * Encryption is done using your application's public key that you provided + during onboarding. Decryption of the payload should be using the private key + of that key-pair. + + * Current encryption algorithms used: + * **RSA-OAEP** (for content key wrapping) + * **AES256GCM** (for content encrytion) + + + ### Sample Code in `NodeJS` + + ``` + // Sample Code for decrypting JWE + // Decrypt JWE using private key + function decryptJWE(header, encryptedKey, iv, cipherText, tag, privateKey) { + + return new Promise((resolve, reject) => { + + var keystore = jose.JWK.createKeyStore(); + + var data = { + "type": "compact", + "ciphertext": cipherText, + "protected": header, + "encrypted_key": encryptedKey, + "tag": tag, + "iv": iv, + "header": JSON.parse(jose.util.base64url.decode(header).toString()) + }; + keystore.add(fs.readFileSync(privateKey, 'utf8'), "pem") + .then(function(jweKey) { + // {result} is a jose.JWK.Key + jose.JWE.createDecrypt(jweKey) + .decrypt(data) + .then(function(result) { + resolve(JSON.parse(result.payload.toString())); + }) + .catch(function(error) { + reject(error); + }); + }); + + }) + .catch (error => { + throw "Error with decrypting JWE"; + }) + } + + ``` + + # Error Handling + + + The RESTful APIs used HTTP specification standard status codes to indicate + the success or failure of each request. Except gateway errors, the response + content will be in the following JSON format: + + + ``` + + { + "code": "integer (int32)", + "message": "string" + } + + ``` + + > **Refer to the individual API definitions for the error codes you might + encounter for each API.** + + + + # Support + + Please refer to the [MyInfo Developer and Partner + Portal](https://www.ndi-api.gov.sg/) for the following supporting materials + where relevant: + + - [Code reference + tables](https://www.ndi-api.gov.sg/assets/lib/trusted-data/myinfo/downloads/myinfo-api-code-tables.xlsx) + + - [Test accounts for sandbox/staging + environments](https://www.ndi-api.gov.sg/library/trusted-data/myinfo/resources-personas) + + - [Reference user journey + templates](https://www.ndi-api.gov.sg/library/trusted-data/myinfo/implementation-reference-journey) + + + + For technical queries, contact + [support@myinfo.gov.sg](mailto:support@myinfo.gov.sg). + + + For business queries, contact + [partner@myinfo.gov.sg](mailto:partner@myinfo.gov.sg). + + + # Authentication + + + +tags: + - name: MyInfo + description: RESTful API Definitions +x-tagGroups: + - name: API Definitions + tags: + - MyInfo +paths: + '/v2/person-sample/{uinfin}/': + get: + tags: + - MyInfo + summary: Person-Sample + description: >- + Retrieves a sample Person data from MyInfo based on UIN/FIN. This API + does not require authorisation token. + + + **Note:** Null value indicates that an attribute is unavailable. + servers: + - url: 'https://myinfosgstg.api.gov.sg/dev' + description: Sandbox + operationId: getpersonsample + parameters: + - in: path + name: uinfin + required: true + schema: + type: string + - in: query + name: attributes + description: >- + Comma separated list of attributes requested. + + + Possible attributes are listed in the Person object definition + below. + style: form + schema: + type: array + items: + type: string + minimum: 1 + x-code-samples: + - lang: Shell + source: 'curl https://myinfosgstg.api.gov.sg/dev/v2/person-sample/S9203266C/' + responses: + '200': + description: |- + OK. + + **Note:** + - Response will be a JSON object. + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + /v2/authorise: + get: + tags: + - MyInfo + summary: Authorise + description: >- + This API triggers SingPass login and obtain consent for the user to + retrieve user's data from MyInfo. + + Once the user has authenticated and consented, an authorisation code + (authcode) will be returned together with the state for verification via + the callback URL defined. + + The authcode can then be used to retrieve an access token via the Token + API. + + + **Note:** This API is public and should be implemented as a link or + button on your online webpage. + operationId: getauthorise + parameters: + - in: query + name: authmode + description: >- + Mode of authentication used to obtain user consent. Default is + "SINGPASS". + required: false + schema: + type: string + enum: + - SINGPASS + - MOBILEID + default: SINGPASS + - in: query + name: purpose + description: >- + State the purpose for requesting the data. This will be shown to the + user for his consent. + allowEmptyValue: false + required: true + schema: + type: string + - in: query + name: response_type + description: Response type for authorisation code flow - must be "code". + allowEmptyValue: false + required: false + schema: + type: string + default: code + - in: query + name: attributes + description: >- + Comma separated list of attributes requested. Possible attributes + are listed in the scopes of the OAuth2 Security Schema above. + required: true + style: form + schema: + type: array + items: + type: string + minimum: 1 + - in: query + name: state + description: >- + Identifier to reconcile query and response. This will be sent back + to you via the callback URL. Use a unique system generated number + for each and every call. + allowEmptyValue: false + required: true + schema: + type: string + - in: query + name: redirect_uri + description: Your callback URL for MyInfo to return with the authorisation code. + allowEmptyValue: false + required: true + schema: + type: string + - in: query + name: client_id + description: Unique ID for your application. + allowEmptyValue: false + required: true + schema: + type: string + responses: + '302': + description: >- + Service will redirect all responses to 'redirect_uri' with + additional parameters added as response results. Expected parameters + include: + + - **code**: this is the authorisation code you will use when calling + the token endpoint + + - **state**: this should be the same state passed in your initial + URL. + + - **error**: if there are any errors encountered, the error code + will be given in this parameter. If user did not give consent, error + value will be 'access_denied', and refer to error_description + parameter for the reason. + - **'404'** - User not found or not registered with MyInfo. + - **'428'** - Profile Incomplete. User registered for MyInfo less than 1 working day. + - **'500'** - Unknown or other server side errors. + - **'503'** - MyInfo under maintenance. Error description will also be given in error_description parameter. + - **error_description**: if error is 'access_denied' i.e. user did + not give consent, the description will be 'Resource Owner did not + authorize the request'. + + + + **Note:** If user closes the browser window prematurely, there will + be no callback to the 'redirect_uri'. Therefore it is important for + you to check the 'state' to verify that the transaction is the same. + x-code-samples: + - lang: JavaScript + source: | + function callAuthoriseApi() { + var authoriseUrl = authApiUrl + "?client_id=" + clientId + + "&attributes=" + attributes + + "&purpose=" + purpose + + "&state=" + state + + "&redirect_uri=" + redirectUrl; + + window.location = authoriseUrl; + } + /v2/token: + post: + tags: + - MyInfo + summary: Token + description: >- + This API generates an access token when presented with a valid authcode + obtained from the Authorise API. This token can then be used to request + for the user's data that were consented. + operationId: gettoken + parameters: + - in: header + name: Authorization + description: >- + Add authorization token constructed containing the RSA digital + signature of the base string. Refer to + https://www.ndi-api.gov.sg/library/trusted-data/myinfo/tutorial3 on + how this token should be generated. + + + **Note:** This header is not required when calling Sandbox API. + required: true + schema: + type: string + responses: + '200': + description: >- + OK. returning a JSON object which contains the authorisation access + token (JWT) that will be used to retrieve the data from MyInfo. + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponse' + '400': + description: 'AuthCode error - missing, invalid, expired, revoked' + content: + application/json: + schema: + $ref: '#/components/schemas/TokenError' + '401': + description: |- + Possible scenarios: + - No security header given + - Invalid App ID used. Digital service is not registered with MyInfo + - The timestamp of server is not synchronised. Check timestamp of server + - The value of the nonce in the authorisation header was deemed to be repeated. Check that the nonce is not re-used + - Ensure Apex header to be 'apex_l2_eg' and not 'apex', resulting in 'No security header' error message + content: + application/json: + schema: + $ref: '#/components/schemas/TokenError' + '404': + description: |- + Possible scenarios: + - Wrong Token API URL used. Refer to tutorial for the correct Token API URL(staging/production) + - Same authcode in the body is being used. We do not allow same authcode being used in multiple calls. Ensure that authcode is not repeated. + content: + application/json: + schema: + $ref: '#/components/schemas/TokenError' + '500': + description: |- + Unexpected error. Error message will be in the response body. + + Possible scenarios: + - Error message: Signature length incorrect. Issue is due to error in digital signature. - Verify your signature by using our signature verifier tool. - Ensure correct key is used to sign the base string. + - Ensure there is a slash (/) after UINFIN in the request URL. E.g. `https://myinfosg.api.gov.sg/v2/person/S9812381D/` + - Attributes should be ordered in lexicographical byte value + - Ensure API URL in the base string include '.e' - `https://myinfosg.e.api.gov.sg` + - Ensure attributes in base string are separate by comma(,), and not %2C + - Signature generated is incorrect. Ensure the correct private key used to produce the signature, and base string is UTF-8 encoded. + - Ensure UIN/FIN is specified in the Person API URL. E.g. `https://myinfosg.e.api.gov.sg/v2/person/` + - Ensure the base string contains the following: + 1. HTTP GET method(in uppcase) + 2. API (e.g. https://..) + 3. These 6 APEX parameters: + * apex_l2_eg_app_id + * apex_l2_eg_nonce + * apex_l2_eg_signature + * apex_l2_eg_signature_method + * apex_l2_eg_timestamp + * apex_l2_eg_version + requestBody: + $ref: '#/components/requestBodies/gettoken' + x-code-samples: + - lang: NodeJS + source: | + // function to prepare request for TOKEN API + function createTokenRequest(code) { + var cacheCtl = "no-cache"; + var contentType = "application/x-www-form-urlencoded"; + var method = "POST"; + var request = null; + + // preparing the request with header and parameters + // assemble params for Token API + var strParams = "grant_type=authorization_code" + + "&code=" + code + + "&redirect_uri=" + _redirectUrl + + "&client_id=" + _clientId + + "&client_secret=" + _clientSecret; + var params = querystring.parse(strParams); + + + // assemble headers for Token API + var strHeaders = "Content-Type=" + contentType + "&Cache-Control=" + cacheCtl; + var headers = querystring.parse(strHeaders); + + // Sign request and add Authorization Headers + var authHeaders = generateAuthorizationHeader( + _tokenApiUrl, + params, + method, + contentType, + _authLevel, + _clientId, + _privateKeyContent, + _clientSecret, + _realm + ); + + if (!_.isEmpty(authHeaders)) { + _.set(headers, "Authorization", authHeaders); + } + + var request = restClient.post(_tokenApiUrl); + + // Set headers + if (!_.isUndefined(headers) && !_.isEmpty(headers)) + request.set(headers); + + // Set Params + if (!_.isUndefined(params) && !_.isEmpty(params)) + request.send(params); + + return request; + } + '/v2/person/{uinfin}/': + get: + tags: + - MyInfo + summary: Person + description: >- + This API returns user's data from MyInfo when presented with a valid + access token obtained from the Token API. + + + **Note:** Null value indicates that an attribute is unavailable. + operationId: getperson + parameters: + - in: header + name: Authorization + description: >- + Add authorization token constructed containing the RSA digital + signature of the base string. Refer to + https://www.ndi-api.gov.sg/library/trusted-data/myinfo/tutorial3 on + how this token should be generated. Also include the access token + (JWT) from /token API in your header prefixed with 'Bearer'. + + + **Note:** Only the Bearer token is required when calling Sandbox + API. + required: true + schema: + type: string + - in: query + name: txnNo + description: >- + Transaction ID from requesting digital services for cross + referencing. + required: false + schema: + type: string + - in: path + name: uinfin + description: UIN/FIN of user + required: true + schema: + type: string + - in: query + name: attributes + required: true + description: >- + Comma separated list of attributes requested. Possible attributes + are listed in the scopes of the OAuth2 Security Schema above. + style: form + schema: + type: array + items: + type: string + minimum: 1 + - in: query + name: client_id + description: Unique ID for your application. + allowEmptyValue: false + required: true + schema: + type: string + responses: + '200': + description: >- + OK. + + + **Note:** + + - Response Content-Encoding will be 'gzip'. + + - Response Content-Type will be 'application/jose', which is a JSON + object conforming to the JWE standard + (https://tools.ietf.org/html/rfc7516). + + - Your application should decrypt the signature with your + application's private key. + + - After decrypting the signature, your application will be able to + extract the payload in JSON format. + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + '401': + description: |- + Unauthorized. This could be due to the scenarios below: + - No security header given + - Invalid App ID used. Digital service is not registered with MyInfo + - The timestamp of server is not synchronised. Check timestamp of server + - The value of the nonce in the authorisation header was deemed to be repeated. Check that the nonce is not re-used + - Ensure Apex header to be 'apex_l2_eg' and not 'apex', resulting in 'No security header' error message + - The requested UIN/FIN does not match the UIN/FIN of the person who logged in + - Request contains attributes not allowable for the digital service. + + Details will be given in the error object returned. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: |- + Possible scenarios: + + - Forbidden. Digital service is not registered with MyInfo. + - Requested attributes does not match the attributes consented by person. + This happens if the list of attributes in your request are different from the attributes specified when calling the token API. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: |- + Not Found. UIN/FIN does not exist in MyInfo. + + **MESSAGE:** 'UIN/FIN is invalid.' + + Possible scenarios: + - Wrong Person API URL used. Refer to tutorial for the correct Person API URL(staging/production) + - UIN/FIN has a SingPass account, but does not have a MyInfo profile + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: >- + Error with request parameters or headers. Error details will be + provided in the response body. + + Possible scenarios: + - Error message: Signature length incorrect. Issue is due to error in digital signature. - Verify your signature by using our signature verifier tool. - Ensure correct key is used to sign the base string. + - Ensure there is a slash (/) after UINFIN in the request URL. E.g. `https://myinfosg.api.gov.sg/v2/person/S9812381D/` + - Attributes should be ordered in lexicographical byte value + - Ensure API URL in the base string include '.e' - `https://myinfosg.e.api.gov.sg` + - Ensure attributes in base string are separate by comma(,), and not %2C + - Signature generated is incorrect. Ensure the correct private key used to produce the signature, and base string is UTF-8 encoded. + - Ensure UIN/FIN is specified in the Person API URL. E.g. `https://myinfosg.e.api.gov.sg/v2/person/` + - Ensure the base string contains the following: + 1. HTTP GET method(in uppcase) + 2. API (e.g. https://..) + 3. These 6 APEX parameters: + * apex_l2_eg_app_id + * apex_l2_eg_nonce + * apex_l2_eg_signature + * apex_l2_eg_signature_method + * apex_l2_eg_timestamp + * apex_l2_eg_version + x-code-samples: + - lang: NodeJS + source: | + // function to prepare request for PERSON API + function createPersonRequest(uinfin, validToken) { + var url = _personApiUrl + "/" + uinfin + "/"; + var cacheCtl = "no-cache"; + var method = "GET"; + var request = null; + // assemble params for Person API + var strParams = "client_id=" + _clientId + + "&attributes=" + _attributes; + var params = querystring.parse(strParams); + + // assemble headers for Person API + var strHeaders = "Cache-Control=" + cacheCtl; + var headers = querystring.parse(strHeaders); + var authHeaders; + + // Sign request and add Authorization Headers + authHeaders = generateAuthorizationHeader( + url, + params, + method, + "", // no content type needed for GET + _authLevel, + _clientId, + _privateKeyContent, + _clientSecret, + _realm + ); + + if (!_.isEmpty(authHeaders)) { + _.set(headers, "Authorization", authHeaders + ",Bearer " + validToken); + } + else { + // NOTE: include access token in Authorization header as "Bearer " (with space behind) + _.set(headers, "Authorization", "Bearer " + validToken); + } + + // invoke token API + var request = restClient.get(url); + + // Set headers + if (!_.isUndefined(headers) && !_.isEmpty(headers)) + request.set(headers); + + // Set Params + if (!_.isUndefined(params) && !_.isEmpty(params)) + request.query(params); + + return request; + } + security: [] +components: + requestBodies: + gettoken: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + code: + description: The authcode given by the authorise API. + type: string + grant_type: + description: Grant type for getting token (default "authorization_code") + type: string + default: authorization_code + client_secret: + description: Secret key given to your application during onboarding. + type: string + client_id: + description: Unique ID for your application. + type: string + redirect_uri: + description: Your callback URL for MyInfo to validate. + type: string + required: + - code + - client_secret + - client_id + - redirect_uri + securitySchemes: + oauth2: + description: | + The following are the available OAuth2 scopes for MyInfo APIs + type: oauth2 + flows: + authorizationCode: + authorizationUrl: /authorise + tokenUrl: /token + scopes: + name: Full Name + hanyupinyinname: Han Yu Pin Yin name + aliasname: Alias name + hanyupinyinaliasname: Han Yu Pin Yin Alias name + marriedname: Married name + sex: Sex + race: Race + secondaryrace: Secondary Race + dialect: Dialect + nationality: Nationality + dob: Date of Birth + birthcountry: Country of Birth + residentialstatus: Residential Status + passportnumber: Passport Number + passportexpirydate: Passport Expiry Date + regadd: Registered Address + mailadd: Mailing Address + billadd: Billing Address + housingtype: Housing Type + hdbtype: HDB Type + ownerprivate: Ownership of Private Property Status + email: Email Address + homeno: Home Contact Number + mobileno: Mobile Number + marital: Marital Status + marriagecertno: Certificate number of the latest marriage + countryofmarriage: Country of the latest marriage + marriagedate: Latest Marriage Date + divorcedate: Last Divorce Date + childrenbirthrecords: Details of Children Birth Records + relationships: Details of Relationships + edulevel: Highest Education Level + gradyear: Year of Graduation + schoolname: Name of School + occupation: Occupation + employment: Employer's Name + workpassstatus: Work pass status of a FIN holder + workpassexpirydate: Work pass expiry of a foreigner + householdincome: Household Income + assessableincome: Latest Assessable Income + assessyear: Year of Assessment (for Assessable Income) + cpfcontributions: Employer CPF Contributions + cpfbalances: CPF Balances + vehno: Vehicle Number + schemas: + AuthTokenResponse: + description: Authentication Token Response JSON + type: object + properties: + token_type: + type: string + description: Type of token (Bearer) + default: Bearer + id_token: + type: string + description: >- + Id token of the person who logged in. This is in the form of JWT + (JSON web token). + + + **Note:** This is not used. + access_token: + $ref: '#/components/schemas/JWTAccessToken' + JWTAccessToken: + title: JWTAccessToken + description: >- + Access token to be used in the subsequent 'person' endpoint call. This + is in the form of JWT (JSON web token). Include this in your header as + 'Bearer' when invoking the 'person' API. This JWT complies to the + standard 'JSON Web Token (JWT) Profile for OAuth 2.0 Client + Authentication and Authorization Grants' + (https://tools.ietf.org/html/rfc7523). + + + **Note:** This token is returned in the form of a compact serialized + string. Decode and verify the signature before use. + type: object + properties: + tokenName: + type: string + description: Name of Token. This will be 'access_token'. + default: access_token + sub: + type: string + description: (subject) This is the 'uinfin' of the user who logged in. + scope: + type: array + description: Scopes allowed. This is the list attributes user consented to share. + items: + type: string + nbf: + type: string + description: >- + (not before) - the time before which the token MUST NOT be accepted + for processing + iss: + type: string + description: (issuer) Issuer of the JWT. + expires_in: + type: string + description: The remaining lifetime of the access token. + iat: + type: string + description: (issued at) Time which JWT was issued at. + exp: + type: string + description: (expiration time) time which JWT will expire + realm: + type: string + description: Realm for OAuth process. Default "/myinfo" + aud: + type: string + description: Audience for JWT. Default "myinfo". + jti: + type: string + description: (JWT ID) unique identifier for the JWT token. + token_type: + type: string + description: 'type of token, which is "Bearer".' + default: Bearer + authGrantId: + type: string + description: Internal system id for auth grant. Not used. + auditTrackingId: + type: string + description: Internal Id for audit tracking. not used. + Person: + type: object + properties: + name: + type: object + title: Name + description: Full Name of the Person. + properties: + value: + type: string + maxLength: 66 + description: 'value of the field, should be displayed as it is.' + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + hanyupinyinname: + type: object + title: HanYuPinYin + description: >- + Han Yu Pin Yin name of the Person. + + + *Presentation Logic - If there is a value to `hanyupinyinname` (i.e. + not empty), then `hanyupinyinname` should be displayed in a new line + below `name`, and formatted with round brackets i.e. + "(`hanyupinyinname`)"'* + properties: + value: + type: string + maxLength: 66 + description: value of the field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + aliasname: + type: object + title: Alias + description: >- + Alias name of the Person. + + + *Presentation Logic - If there is a value to `aliasname` (i.e. not + empty), then `aliasname` should be displayed in a new line below + `hanyupinyinname`, and prefixed with the ''@'' symbol i.e. + "@`aliasname`".'* + properties: + value: + type: string + maxLength: 66 + description: value of the field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + hanyupinyinaliasname: + type: object + title: HanYuPinYinAlias + description: >- + Han Yu Pin Yin Alias name of the Person. + + + *Presentation Logic - If there is a value to `hanyupinyinaliasname` + (i.e. not empty), then `hanyupinyinaliasname` should be displayed in + a new line below `aliasname`, and prefixed with the ''@'' symbol + i.e. "@`hanyupinyinaliasname`".* + properties: + value: + type: string + maxLength: 66 + description: value of the field + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + marriedname: + type: object + title: MarriedName + description: >- + Married name of the Person. + + + *Presentation Logic - If there is a value to `marriedname` (i.e. not + empty), then `marriedname` should be displayed in a new line below + `hanyupinyinaliasname`.* + properties: + value: + type: string + maxLength: 66 + description: 'value of the field, should be displayed as it is.' + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + sex: + type: object + title: Sex + description: |- + Sex of Person. + * 'F' - Female + * 'M' - Male + * 'U' - Unknown + properties: + value: + type: string + enum: + - F + - M + - U + maxLength: 1 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + race: + type: object + title: Race + description: >- + Race of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + value: + type: string + pattern: '[a-zA-Z]{2}' + maxLength: 2 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + secondaryrace: + type: object + title: SecondaryRace + description: >- + Secondary Race of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + value: + type: string + pattern: '[a-zA-Z]{2}' + maxLength: 2 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + dialect: + type: object + title: Dialect + description: >- + Dialect of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + value: + type: string + pattern: '[a-zA-Z]{2}' + maxLength: 2 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + nationality: + type: object + title: Nationality + description: >- + Nationality of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + value: + type: string + pattern: '[a-zA-Z]{2}' + maxLength: 2 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + dob: + type: object + title: DOB + description: Date of Birth of Person. + properties: + value: + type: string + format: date + description: >- + Value of data field. See "full-date" in + http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + birthcountry: + type: object + title: BirthCountry + description: >- + Country of Birth of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + value: + type: string + pattern: '[a-zA-Z]{2}' + maxLength: 2 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + residentialstatus: + type: object + title: ResidentialStatus + description: >- + Residential Status of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + code: + type: string + pattern: '[a-zA-Z]{1}' + maxLength: 1 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + passportnumber: + type: object + title: PassportNumber + description: >- + Passport Number of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + code: + type: string + maxLength: 25 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + passportexpirydate: + type: object + title: PassportExpiryDate + description: >- + Passport Expiry Date of Person. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + properties: + code: + type: string + format: date + description: >- + Value of data field. See "full-date" in + http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + regadd: + type: object + title: RegAdd + description: Registered Address of Person + allOf: + - $ref: '#/components/schemas/AddressLocal' + - $ref: '#/components/schemas/DataFieldProperties' + mailadd: + type: object + title: MailAdd + description: Mailing Address of Person + allOf: + - $ref: '#/components/schemas/AddressLocal' + - $ref: '#/components/schemas/DataFieldProperties' + billadd: + type: object + title: BillAdd + description: Billing Address of Person + allOf: + - $ref: '#/components/schemas/AddressLocal' + - $ref: '#/components/schemas/DataFieldProperties' + housingtype: + type: object + title: HousingType + description: |- + Housing Type of Person (non-HDB only). + + **Note:** This value will be null if housing type is HDB; + + Refer to `hdbtype` for detailed HDB type. + + * '121' - DETACHED HOUSE + * '122' - SEMI-DETACHED HOUSE + * '123' - TERRACE HOUSE + * '131' - CONDOMINIUM + * '132' - EXECUTIVE CONDOMINIUM + * '139' - APARTMENT + properties: + value: + type: string + description: value of data field + enum: + - 121 + - 122 + - 123 + - 131 + - 132 + - 139 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + hdbtype: + type: object + title: HDBType + description: |- + HDB Type of Person (HDB only). + + **Note:** This value will be null if housing type is not HDB. + + * '111' - 1-ROOM FLAT (HDB) + * '112' - 2-ROOM FLAT (HDB) + * '113' - 3-ROOM FLAT (HDB) + * '114' - 4-ROOM FLAT (HDB) + * '115' - 5-ROOM FLAT (HDB) + * '116' - EXECUTIVE FLAT (HDB) + * '118' - STUDIO APARTMENT (HDB) + properties: + value: + type: string + description: Value of data field + enum: + - 111 + - 112 + - 113 + - 114 + - 115 + - 116 + - 118 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + ownerprivate: + type: object + title: OwnerPrivate + description: >- + Ownership of Private Property Status of Person (based on IRAS + information). + + + * true + + * false + + * null (data not available) + properties: + value: + type: boolean + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + email: + type: object + title: Email + description: Email Address of Person. + properties: + value: + type: string + maxLength: 320 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + homeno: + type: object + title: HomeNo + description: Home Contact Number of Person. + allOf: + - $ref: '#/components/schemas/PhoneNumLocal' + - $ref: '#/components/schemas/DataFieldProperties' + mobileno: + type: object + title: MobileNo + description: Mobile Number of Person. + allOf: + - $ref: '#/components/schemas/PhoneNumLocal' + - $ref: '#/components/schemas/DataFieldProperties' + marital: + type: object + title: Marital + description: >- + Marital Status of Person. + + + * '1' - SINGLE + + * '2' - MARRIED + + * '3' - WIDOWED + + * '5' - DIVORCED + + + **Note:** This field must be made **editable** on your digital + service form even though `source` is '1' (Government Verified). + properties: + value: + type: string + description: value of data field + enum: + - 1 + - 2 + - 3 + - 5 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + marriagecertno: + type: object + title: MarriageCertNo + description: >- + Certificate number of the latest marriage. + + + + **Note:** This field must be made **editable** on your digital + service form even though `source` is '1' (Government Verified). + properties: + value: + type: string + maxLength: 15 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + countryofmarriage: + type: object + title: CountryOfMarriage + description: >- + Country of the latest marriage. + + + + **Note:** This field must be made **editable** on your digital + service form even though `source` is '1' (Government Verified). + properties: + value: + type: string + maxLength: 2 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + marriagedate: + type: object + title: MarriageDate + description: > + Latest Marriage Date of Person. + + + + **Note:** This field must be made **editable** on your digital + service form even though `source` is '1' (Government Verified). + properties: + value: + type: string + format: date + description: >- + Value of data field. + + + See "full-date" in + http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + divorcedate: + type: object + title: DivorceDate + description: > + Last Divorce Date of Person. + + + + **Note:** This field must be made **editable** on your digital + service form even though `source` is '1' (Government Verified). + properties: + value: + type: string + format: date + description: >- + Value of data field. + + + See "full-date" in + http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + childrenbirthrecords: + type: object + title: ChildrenBirthRecords + description: >- + This refers to only local registered birth records(based on ICA’s + electronic Birth Certificate Records from 1985 onwards). + + + This includes adoption of locally registered child. + + + For child below 21, the child’s Birth Cert No, Name, Sex, Race, + Dialect, Date of Birth and Time of Birth will be shown. + + + For child above 21, only the child’s Birth Cert Number will be + shown. + properties: + birthrecords: + type: array + items: + $ref: '#/components/schemas/ChildBirthRecord' + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + relationships: + type: array + deprecated: true + title: Relationships + items: + $ref: '#/components/schemas/Relationship' + description: Details of Relationships of Person + edulevel: + type: object + title: EduLevel + description: >- + Highest Education Level of Person. + + + * '0' - NO FORMAL QUALIFICATION / PRE-PRIMARY / LOWER PRIMARY + + * '1' - PRIMARY + + * '2' - LOWER SECONDARY + + * '3' - SECONDARY + + * '4' - POST-SECONDARY (NON-TERTIARY): GENERAL & VOCATION + + * '5' - POLYTECHNIC DIPLOMA + + * '6' - PROFESSIONAL QUALIFICATION AND OTHER DIPLOMA + + * '7' - BACHELOR'S OR EQUIVALENT + + * '8' - POSTGRADUATE DIPLOMA / CERTIFICATE (EXCLUDING MASTER'S AND + DOCTORATE) + + * '9' - MASTER'S AND DOCTORATE OR EQUIVALENT + + * 'N' - MODULAR CERTIFICATION (NON-AWARD COURSES / NON-FULL + QUALIFICATIONS) + properties: + value: + type: string + description: value of data field + enum: + - '0' + - '1' + - '2' + - '3' + - '4' + - '5' + - '6' + - '7' + - '8' + - '9' + - 'N' + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + gradyear: + type: object + title: GradYear + description: 'Year of Graduation of Person. Format: YYYY' + properties: + value: + type: string + pattern: '[0-9]{4}' + maxLength: 4 + description: value of data field + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + schoolname: + type: object + title: SchoolName + description: 'Name of School of Person. ' + properties: + type: + type: string + maxLength: 10 + description: >- + Code value of school name. + + + Refer to the [Code reference tables](#section/Support) for list + of possible school names. + + + **Note:** Code and desc are mutually exclusive. + desc: + type: string + maxLength: 100 + description: |- + Free text value of school name. + **Note:** Code and desc are mutually exclusive. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + occupation: + type: object + title: Occupation + description: 'Occupation of Person. ' + properties: + value: + type: string + maxLength: 5 + pattern: '[0-9]{5}' + description: >- + Code value of occupation based on SSOC 2015. + + + For full list, refer to SSOC 2015 at + https://www.singstat.gov.sg/standards/standards-and-classifications/ssoc. + + + For FIN holders, blank will be returned. + + + **Note:** Value and desc are mutually exclusive. + desc: + type: string + maxLength: 100 + description: |- + Free text value of occupation. + + **Note:** Value and desc are mutually exclusive. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + employment: + type: object + title: Employment + description: Employer's Name of Person. + properties: + value: + type: string + maxLength: 124 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + workpassstatus: + type: object + title: WorkPassStatus + description: "Work pass status of a FIN holder.
**Note:** Only applies to a foreigner with a valid work pass. \n*\tLive\n*\tApproved\n" + properties: + value: + type: string + enum: + - Live + - Approved + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + workpassexpirydate: + type: object + title: WorkPassExpiryDate + description: >- + Work pass expiry of a foreigner.
**Note:** Only applies to a + foreigner with a valid work pass. + properties: + value: + type: string + format: date + description: >- + Value of data field. See "full-date" in + http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14 + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + householdincome: + type: object + title: HouseholdIncome + description: Household Income of Person in SGD. + properties: + high: + type: number + format: double + description: upper bound of the range of household income bracket + low: + type: number + format: double + description: lower bound of the range of household income bracket + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + assessableincome: + type: object + title: AssessableIncome + description: Latest Assessable Income of Person in SGD. + properties: + value: + type: number + format: double + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + assessyear: + type: object + title: AssessYear + description: 'Year of Assessment (for Assessable Income). Format: YYYY' + properties: + value: + type: string + pattern: '[0-9]{4}' + maxLength: 4 + description: value of data field + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + cpfcontributions: + type: object + title: CPFContributions + description: >- + Employer CPF Contributions of Person in SGD. Does not include any + non-employer contributions. Maximum past 14 months' of + contributions. + properties: + contributions: + type: array + items: + $ref: '#/components/schemas/CPFContribution' + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + cpfbalances: + type: object + title: CPFBalances + description: CPF Balances of Person in SGD. + properties: + ma: + type: number + format: double + description: Amount Balance in CPF-MA + oa: + type: number + format: double + description: Amount Balance in CPF-OA + sa: + type: number + format: double + description: Amount Balance in CPF-SA + ra: + type: number + format: double + description: Amount Balance in CPF-RA + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + vehno: + type: object + title: VehNo + description: Vehicle Number of Person. + properties: + value: + type: string + maxLength: 12 + description: Value of data field. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + example: + name: + lastupdated: '2015-06-01' + source: '1' + classification: C + value: TAN XIAO HUI + hanyupinyinname: + lastupdated: '2015-06-01' + source: '1' + classification: C + value: CHEN XIAO HUI + aliasname: + lastupdated: '2015-06-01' + source: '1' + classification: C + value: TRICIA TAN XIAO HUI + hanyupinyinaliasname: + lastupdated: '2015-06-01' + source: '1' + classification: C + value: '' + marriedname: + lastupdated: '2015-06-01' + source: '1' + classification: C + value: '' + sex: + lastupdated: '2016-03-11' + source: '1' + classification: C + value: F + race: + lastupdated: '2016-03-11' + source: '1' + classification: C + value: CN + secondaryrace: + lastupdated: '2017-08-25' + source: '1' + classification: C + value: EU + dialect: + lastupdated: '2016-03-11' + source: '1' + classification: C + value: SG + nationality: + lastupdated: '2016-03-11' + source: '1' + classification: C + value: SG + dob: + lastupdated: '2016-03-11' + source: '1' + classification: C + value: '1958-05-17' + birthcountry: + lastupdated: '2016-03-11' + source: '1' + classification: C + value: SG + residentialstatus: + lastupdated: '2017-08-25' + source: '1' + classification: C + value: C + passportnumber: + lastupdated: '2017-08-25' + source: '1' + classification: C + value: E35463874W + passportexpirydate: + lastupdated: '2017-08-25' + source: '1' + classification: C + value: '2020-01-01' + regadd: + country: SG + unit: '128' + street: BEDOK NORTH AVENUE 1 + lastupdated: '2016-03-11' + block: '548' + source: '1' + postal: '460548' + classification: C + floor: 09 + building: '' + mailadd: + country: SG + unit: '128' + street: BEDOK NORTH AVENUE 1 + lastupdated: '2016-03-11' + block: '548' + source: '2' + postal: '460548' + classification: C + floor: 09 + building: '' + billadd: + country: SG + unit: '' + street: '' + lastupdated: '' + block: '' + source: '' + postal: '' + classification: '' + floor: '' + building: '' + housingtype: + lastupdated: '2015-12-23' + source: '1' + classification: C + value: '' + hdbtype: + lastupdated: '2015-12-23' + source: '1' + classification: C + value: '111' + ownerprivate: + lastupdated: '2015-12-23' + source: '1' + classification: C + value: 'N' + email: + lastupdated: '2017-12-13' + source: '4' + classification: C + value: test@gmail.com + homeno: + code: '65' + prefix: + + lastupdated: '2017-11-20' + source: '2' + classification: C + nbr: '66132665' + mobileno: + code: '65' + prefix: + + lastupdated: '2017-12-13' + source: '4' + classification: C + nbr: '97324992' + marital: + lastupdated: '2017-03-29' + source: '1' + classification: C + value: '1' + marriagecertno: + lastupdated: '2018-03-02' + source: '1' + classification: C + value: '123456789012345' + countryofmarriage: + lastupdated: '2018-03-02' + source: '1' + classification: C + value: SG + marriagedate: + lastupdated: '' + source: '1' + classification: C + value: '' + divorcedate: + lastupdated: '' + source: '1' + classification: C + value: '' + childrenbirthrecords: + - dialect: HK + race: CN + tob: 0901 + sex: F + source: '1' + classification: C + birthcertno: S5562882C + hanyupinyinname: Cheng Pei Ni + hanyupinyinaliasname: '' + marriedname: '' + aliasname: '' + dob: '2011-09-10' + name: Jo Tan Pei Ni + lastupdated: '2018-05-16' + secondaryrace: '' + - dialect: HK + race: CN + tob: '2021' + sex: F + source: '1' + classification: C + birthcertno: S8816582I + hanyupinyinname: Cheng Wei Ling + hanyupinyinaliasname: '' + marriedname: '' + aliasname: '' + dob: '2015-07-18' + name: Joyce Tan Wei Ling + lastupdated: '2018-05-16' + secondaryrace: '' + - dialect: HK + race: CN + tob: 0901 + sex: F + source: '1' + classification: C + birthcertno: T0202564C + hanyupinyinname: Cheng Shu Hui + hanyupinyinaliasname: '' + marriedname: '' + aliasname: '' + dob: '2012-09-10' + name: Joycelyn Tan Shu Hui + lastupdated: '2018-05-16' + secondaryrace: '' + relationships: + - passportno: '' + name: TAN AH MUI + lastupdated: '2017-10-11' + source: '2' + classification: C + type: REL201 + idno: S9999999C + - passportno: '' + name: TAN CHIN SOON + lastupdated: '2017-10-11' + source: '2' + classification: C + type: REL202 + idno: S9999998E + edulevel: + lastupdated: '2017-10-11' + source: '2' + classification: C + value: '3' + gradyear: + lastupdated: '2017-10-11' + source: '2' + classification: C + value: '1978' + schoolname: + lastupdated: '2017-10-11' + source: '2' + classification: C + value: T07GS3011J + desc: SIGLAP SECONDARY SCHOOL + occupation: + lastupdated: '2017-10-11' + source: '2' + classification: C + value: '53201' + desc: HEALTHCARE ASSISTANT + employment: + lastupdated: '2017-10-11' + source: '2' + classification: C + value: ALPHA + workpassstatus: + lastupdated: '2018-03-02' + source: '1' + classification: C + value: Live + workpassexpirydate: + lastupdated: '2018-03-02' + source: '1' + classification: C + value: '2018-12-31' + householdincome: + high: '5999' + low: '5000' + lastupdated: '2017-10-24' + source: '2' + classification: C + assessableincome: + lastupdated: '2015-12-23' + source: '1' + classification: C + value: '1456789.00' + assessyear: + lastupdated: '2015-12-23' + source: '1' + classification: C + value: '2015' + cpfcontributions: + cpfcontribution: + - date: '2016-12-01' + amount: '500.00' + month: 2016-11 + employer: Crystal Horse Invest Pte Ltd + - date: '2016-12-12' + amount: '500.00' + month: 2016-12 + employer: Crystal Horse Invest Pte Ltd + - date: '2016-12-21' + amount: '500.00' + month: 2016-12 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-01-01' + amount: '500.00' + month: 2016-12 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-01-12' + amount: '500.00' + month: 2017-01 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-01-21' + amount: '500.00' + month: 2017-01 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-02-01' + amount: '500.00' + month: 2017-01 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-02-12' + amount: '500.00' + month: 2017-02 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-02-21' + amount: '500.00' + month: 2017-02 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-03-01' + amount: '500.00' + month: 2017-02 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-03-12' + amount: '500.00' + month: 2017-03 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-03-21' + amount: '500.00' + month: 2017-03 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-04-01' + amount: '500.00' + month: 2017-03 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-04-12' + amount: '500.00' + month: 2017-04 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-04-21' + amount: '500.00' + month: 2017-04 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-05-01' + amount: '500.00' + month: 2017-04 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-05-12' + amount: '500.00' + month: 2017-05 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-05-21' + amount: '500.00' + month: 2017-05 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-06-01' + amount: '500.00' + month: 2017-05 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-06-12' + amount: '500.00' + month: 2017-06 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-06-21' + amount: '500.00' + month: 2017-06 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-07-01' + amount: '500.00' + month: 2017-06 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-07-12' + amount: '500.00' + month: 2017-07 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-07-21' + amount: '500.00' + month: 2017-07 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-08-01' + amount: '500.00' + month: 2017-07 + employer: Crystal Horse Invest Pte Ltd + - date: '2017-08-12' + amount: '750.00' + month: 2017-08 + employer: Delta Marine Consultants PL + - date: '2017-08-21' + amount: '750.00' + month: 2017-08 + employer: Delta Marine Consultants PL + - date: '2017-09-01' + amount: '750.00' + month: 2017-08 + employer: Delta Marine Consultants PL + - date: '2017-09-12' + amount: '750.00' + month: 2017-09 + employer: Delta Marine Consultants PL + - date: '2017-09-21' + amount: '750.00' + month: 2017-09 + employer: Delta Marine Consultants PL + - date: '2017-10-01' + amount: '750.00' + month: 2017-09 + employer: Delta Marine Consultants PL + - date: '2017-10-12' + amount: '750.00' + month: 2017-10 + employer: Delta Marine Consultants PL + - date: '2017-10-21' + amount: '750.00' + month: 2017-10 + employer: Delta Marine Consultants PL + - date: '2017-11-01' + amount: '750.00' + month: 2017-10 + employer: Delta Marine Consultants PL + - date: '2017-11-12' + amount: '750.00' + month: 2017-11 + employer: Delta Marine Consultants PL + - date: '2017-11-21' + amount: '750.00' + month: 2017-11 + employer: Delta Marine Consultants PL + - date: '2017-12-01' + amount: '750.00' + month: 2017-11 + employer: Delta Marine Consultants PL + - date: '2017-12-12' + amount: '750.00' + month: 2017-12 + employer: Delta Marine Consultants PL + - date: '2017-12-21' + amount: '750.00' + month: 2017-12 + employer: Delta Marine Consultants PL + - date: '2018-01-01' + amount: '750.00' + month: 2017-12 + employer: Delta Marine Consultants PL + - date: '2018-01-12' + amount: '750.00' + month: 2018-01 + employer: Delta Marine Consultants PL + - date: '2018-01-21' + amount: '750.00' + month: 2018-01 + employer: Delta Marine Consultants PL + lastupdated: '2015-12-23' + source: '1' + classification: C + cpfbalances: + oa: '1581.48' + ma: '11470.70' + lastupdated: '2015-12-23' + source: '1' + classification: C + sa: '21967.09' + vehno: + lastupdated: '' + source: '2' + classification: C + value: '' + DataFieldProperties: + type: object + properties: + classification: + type: string + maxLength: 1 + enum: + - C + default: C + description: Data classification of data field. Default 'C' - Confidential. + source: + type: string + maxLength: 1 + enum: + - '1' + - '2' + - '3' + - '4' + description: >- + Source of data. + + + * '1' - Government-verified + + * '2' - User provided + + * '3' - Field is Not Applicable to Person + + * '4' - Verified by SingPass + + + **Note:** All Government-verified fields must be **non-editable** on + your digital service form (some exceptions apply - see individual + field descriptions). + lastupdated: + type: string + format: date + description: >- + Last updated date of data field. See "full-date" in + http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14 + AddressLocal: + type: object + properties: + block: + type: string + maxLength: 10 + description: Block of Address + building: + type: string + maxLength: 32 + description: Building of Address + floor: + type: string + maxLength: 3 + description: Floor of Address + unit: + type: string + maxLength: 5 + description: Unit of Address + street: + type: string + maxLength: 32 + description: Street of Address + postal: + type: string + minLength: 6 + maxLength: 6 + description: Postal Code of Address + country: + type: string + minLength: 2 + maxLength: 2 + default: SG + description: Country of Address. For AddressLocal this will always be "SG". + CPFContribution: + type: object + description: CPF contribution + properties: + employer: + type: string + maxLength: 80 + description: Employer who paid the Contribution. + date: + type: string + format: date + description: >- + Date of Contribution Paid. See "full-date" in + http://xml2rfc.ietf.org/public/rfc/htm + month: + type: string + maxLength: 7 + description: 'Month for which CPF Contribution was paid. Format: YYYY-MM' + amount: + type: number + format: double + description: Amount of contribution in SGD + Relationship: + type: object + properties: + type: + type: string + maxLength: 6 + description: | + Type of Relationship. + * REL101 - HUSBAND + * REL102 - WIFE + * REL201 - MOTHER + * REL202 - FATHER + * REL401 - SON + * REL402 - DAUGHTER + * REL601 - BROTHER + * REL602 - SISTER + enum: + - REL101 + - REL102 + - REL201 + - REL202 + - REL401 + - REL402 + - REL601 + - REL602 + name: + type: string + maxLength: 66 + description: Name of family member. + idno: + type: string + maxLength: 9 + description: >- + ID Number (NRIC/FIN) of family member.
**Note:** 'idno' and + 'passportno' are mutually exclusive. + passportno: + type: string + maxLength: 9 + description: >- + Passport Number of family member.
**Note:** 'idno' and + 'passportno' are mutually exclusive. + allOf: + - $ref: '#/components/schemas/DataFieldProperties' + ChildBirthRecord: + type: object + properties: + birthcertno: + type: string + maxLength: 15 + description: Birth certificate number + name: + type: string + maxLength: 66 + description: Full Name of child + hanyupinyinname: + type: string + maxLength: 66 + description: >- + Han Yu Pin Yin name of child. + + + *Presentation Logic - If there is a value to `hanyupinyinname` (i.e. + not empty), then `hanyupinyinname` should be displayed in a new line + below `name`, and formatted with round brackets i.e. + \"(`hanyupinyinname`)\".* + aliasname: + type: string + maxLength: 66 + description: >- + Alias name of child. + + + *Presentation Logic - If there is a value to `aliasname` (i.e. not + empty), then `aliasname` should be displayed in a new line below + `hanyupinyinname`, and prefixed with the ''@'' symbol i.e. + \"@`aliasname`\".* + hanyupinyinaliasname: + type: string + maxLength: 66 + description: >- + Han Yu Pin Yin Alias name of child. + + + *Presentation Logic - If there is a value to `hanyupinyinaliasname` + (i.e. not empty), then `hanyupinyinaliasname` should be displayed in + a new line below `aliasname`, and prefixed with the ''@'' symbol + i.e. \"@`hanyupinyinaliasname`\".* + marriedname: + type: string + maxLength: 66 + title: MarriedName + description: >- + Married name of child. + + + *Presentation Logic - If there is a value to `marriedname` (i.e. not + empty), then `marriedname` should be displayed in a new line below + `hanyupinyinaliasname`.* + sex: + type: string + maxLength: 1 + enum: + - F + - M + - U + description: |- + Sex of child. + * F - Female + * M - Male + * U - Unknown + race: + type: string + maxLength: 2 + pattern: '[a-zA-Z]{2}' + description: >- + Race of child. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + secondaryrace: + type: string + maxLength: 2 + pattern: '[a-zA-Z]{2}' + description: >- + Secondary Race of child. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + dialect: + type: string + maxLength: 2 + pattern: '[a-zA-Z]{2}' + description: >- + Dialect of child. + + + Refer to the [Code reference tables](#section/Support) for list of + possible values. + dob: + type: string + format: date + description: >- + Date of Birth of child. + + + See "full-date" in + http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14 + tob: + type: string + maxLength: 4 + description: |- + Time of Birth of child. + + Format: HHMM + PhoneNumLocal: + type: object + properties: + prefix: + type: string + default: + + maxLength: 1 + description: >- + Prefix of Phone Number. Defaults to '+'. If phone number is blank, + prefix will be returned as blank. + code: + type: string + default: '065' + maxLength: 3 + description: >- + Area Code of Phone Number. Default to '065'. If phone number is + blank, code will be returned as blank. + nbr: + type: string + maxLength: 12 + description: Phone Number. + TokenError: + type: object + properties: + error_description: + type: string + error: + type: string + Error: + type: object + properties: + code: + type: integer + format: int32 + message: + type: string + fields: + type: string diff --git a/neo4j-store/build.gradle b/neo4j-store/build.gradle index 4d1e80163..8d1d7b035 100644 --- a/neo4j-store/build.gradle +++ b/neo4j-store/build.gradle @@ -1,11 +1,8 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } -ext.neo4jVersion="3.4.8" -ext.neo4jDriverVersion="1.6.3" - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions { jvmTarget = "1.8" @@ -19,6 +16,8 @@ repositories { } dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinXCoroutinesVersion" + implementation project(":prime-modules") implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" @@ -26,7 +25,7 @@ dependencies { implementation "org.neo4j.driver:neo4j-java-driver:$neo4jDriverVersion" implementation "org.neo4j:neo4j-slf4j:$neo4jVersion" - testImplementation 'com.palantir.docker.compose:docker-compose-rule-junit4:0.34.0' + testImplementation "com.palantir.docker.compose:docker-compose-rule-junit4:$dockerComposeJunitRuleVersion" testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" @@ -34,4 +33,4 @@ dependencies { testImplementation "org.mockito:mockito-core:$mockitoVersion" } -apply from: '../jacoco.gradle' \ No newline at end of file +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Model.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Model.kt new file mode 100644 index 000000000..d32e52821 --- /dev/null +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Model.kt @@ -0,0 +1,28 @@ +package org.ostelco.prime.storage.graph + +import org.ostelco.prime.model.CustomerRegionStatus +import org.ostelco.prime.model.HasId +import org.ostelco.prime.model.KycStatus +import org.ostelco.prime.model.KycType + +data class Identity( + override val id: String, + val type: String) : HasId + +data class Identifies(val provider: String) + +data class SubscriptionToBundle(val reservedBytes: Long = 0) + +data class PlanSubscription( + val subscriptionId: String, + val created: Long, + val trialEnd: Long) + +data class CustomerRegion( + val status: CustomerRegionStatus, + val kycStatusMap: Map = emptyMap()) + +data class SimProfile( + override val id: String, + val iccId: String, + val alias: String = "") : HasId \ No newline at end of file diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jModule.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jModule.kt index 18ad1be2f..8b09a5c3b 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jModule.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jModule.kt @@ -10,9 +10,12 @@ import org.neo4j.driver.v1.GraphDatabase import org.ostelco.prime.model.Offer import org.ostelco.prime.model.Price import org.ostelco.prime.model.Product +import org.ostelco.prime.model.Region import org.ostelco.prime.model.Segment import org.ostelco.prime.module.PrimeModule import java.net.URI +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols import java.util.concurrent.TimeUnit.SECONDS @JsonTypeName("neo4j") @@ -37,19 +40,24 @@ class Neo4jModule : PrimeModule { } fun initDatabase() { + Neo4jStoreSingleton.createIndex() + + Neo4jStoreSingleton.createRegion(Region(id = "no", name = "Norway")) + Neo4jStoreSingleton.createRegion(Region(id = "sg", name = "Singapore")) + Neo4jStoreSingleton.createProduct(createProduct(sku = "1GB_249NOK", amount = 24900)) Neo4jStoreSingleton.createProduct(createProduct(sku = "2GB_299NOK", amount = 29900)) Neo4jStoreSingleton.createProduct(createProduct(sku = "3GB_349NOK", amount = 34900)) Neo4jStoreSingleton.createProduct(createProduct(sku = "5GB_399NOK", amount = 39900)) Neo4jStoreSingleton.createProduct(Product( - sku = "100MB_FREE_ON_JOINING", - price = Price(0, "NOK"), - properties = mapOf("noOfBytes" to "100_000_000"))) + sku = "2GB_FREE_ON_JOINING", + price = Price(0, ""), + properties = mapOf("noOfBytes" to "2_147_483_648"))) Neo4jStoreSingleton.createProduct(Product( sku = "1GB_FREE_ON_REFERRED", - price = Price(0, "NOK"), - properties = mapOf("noOfBytes" to "1_000_000_000"))) + price = Price(0, ""), + properties = mapOf("noOfBytes" to "1_073_741_824"))) val segments = listOf( Segment(id = getSegmentNameFromCountryCode("NO")), @@ -65,12 +73,11 @@ fun initDatabase() { } // Helper for naming of default segments based on country code. -fun getSegmentNameFromCountryCode(countryCode: String) : String = "country-$countryCode".toLowerCase() +fun getSegmentNameFromCountryCode(countryCode: String): String = "country-$countryCode".toLowerCase() -class Config { - lateinit var host: String - lateinit var protocol: String -} +data class Config( + val host: String, + val protocol: String) object ConfigRegistry { lateinit var config: Config @@ -86,6 +93,7 @@ object Neo4jClient : Managed { val config = org.neo4j.driver.v1.Config.build() .withoutEncryption() .withConnectionTimeout(10, SECONDS) + .withMaxConnectionPoolSize(1000) .toConfig() driver = GraphDatabase.driver( URI("${ConfigRegistry.config.protocol}://${ConfigRegistry.config.host}:7687"), @@ -98,6 +106,11 @@ object Neo4jClient : Managed { } } +private val dfs = DecimalFormatSymbols().apply { + groupingSeparator = '_' +} +private val df = DecimalFormat("#,###", dfs) + fun createProduct(sku: String, amount: Int): Product { // This is messy code @@ -106,6 +119,6 @@ fun createProduct(sku: String, amount: Int): Product { return Product( sku = sku, price = Price(amount = amount, currency = "NOK"), - properties = mapOf("noOfBytes" to "${gbs}_000_000_000"), + properties = mapOf("noOfBytes" to df.format(gbs * Math.pow(2.0, 30.0).toLong())), presentation = mapOf("label" to "$gbs GB for ${amount / 100}")) } diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt index fa99b8217..a15d2f5f0 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt @@ -1,37 +1,56 @@ package org.ostelco.prime.storage.graph import arrow.core.Either -import arrow.core.Tuple3 +import arrow.core.fix import arrow.core.flatMap +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import arrow.effects.IO +import arrow.instances.either.monad.monad import org.neo4j.driver.v1.Transaction import org.ostelco.prime.analytics.AnalyticsService +import org.ostelco.prime.appnotifier.AppNotifier +import org.ostelco.prime.ekyc.DaveKycService +import org.ostelco.prime.ekyc.MyInfoKycService import org.ostelco.prime.getLogger -import org.ostelco.prime.model.Bundle -import org.ostelco.prime.model.ChangeSegment -import org.ostelco.prime.model.Offer -import org.ostelco.prime.model.Product -import org.ostelco.prime.model.ProductClass -import org.ostelco.prime.model.PurchaseRecord -import org.ostelco.prime.model.Segment -import org.ostelco.prime.model.Subscriber -import org.ostelco.prime.model.Subscription +import org.ostelco.prime.model.* +import org.ostelco.prime.model.CustomerRegionStatus.APPROVED +import org.ostelco.prime.model.CustomerRegionStatus.PENDING +import org.ostelco.prime.model.KycStatus.REJECTED +import org.ostelco.prime.model.KycType.ADDRESS_AND_PHONE_NUMBER +import org.ostelco.prime.model.KycType.JUMIO +import org.ostelco.prime.model.KycType.MY_INFO +import org.ostelco.prime.model.KycType.NRIC_FIN import org.ostelco.prime.module.getResource +import org.ostelco.prime.notifications.EmailNotifier import org.ostelco.prime.notifications.NOTIFY_OPS_MARKER -import org.ostelco.prime.ocs.OcsAdminService -import org.ostelco.prime.ocs.OcsSubscriberService import org.ostelco.prime.paymentprocessor.PaymentProcessor import org.ostelco.prime.paymentprocessor.core.BadGatewayError +import org.ostelco.prime.paymentprocessor.core.ForbiddenError import org.ostelco.prime.paymentprocessor.core.PaymentError import org.ostelco.prime.paymentprocessor.core.ProductInfo +import org.ostelco.prime.paymentprocessor.core.ProfileInfo +import org.ostelco.prime.sim.SimManager +import org.ostelco.prime.storage.AlreadyExistsError +import org.ostelco.prime.storage.ConsumptionResult import org.ostelco.prime.storage.GraphStore import org.ostelco.prime.storage.NotCreatedError +import org.ostelco.prime.storage.NotDeletedError import org.ostelco.prime.storage.NotFoundError +import org.ostelco.prime.storage.NotUpdatedError +import org.ostelco.prime.storage.ScanInformationStore import org.ostelco.prime.storage.StoreError +import org.ostelco.prime.storage.SystemError import org.ostelco.prime.storage.ValidationError import org.ostelco.prime.storage.graph.Graph.read +import org.ostelco.prime.storage.graph.Graph.write +import org.ostelco.prime.storage.graph.Graph.writeSuspended import org.ostelco.prime.storage.graph.Relation.BELONG_TO_SEGMENT import org.ostelco.prime.storage.graph.Relation.HAS_BUNDLE +import org.ostelco.prime.storage.graph.Relation.HAS_SIM_PROFILE import org.ostelco.prime.storage.graph.Relation.HAS_SUBSCRIPTION +import org.ostelco.prime.storage.graph.Relation.IDENTIFIES import org.ostelco.prime.storage.graph.Relation.LINKED_TO_BUNDLE import org.ostelco.prime.storage.graph.Relation.OFFERED_TO_SEGMENT import org.ostelco.prime.storage.graph.Relation.OFFER_HAS_PRODUCT @@ -40,16 +59,27 @@ import org.ostelco.prime.storage.graph.Relation.REFERRED import java.time.Instant import java.util.* import java.util.stream.Collectors +import javax.ws.rs.core.MultivaluedMap +import org.ostelco.prime.model.Identity as ModelIdentity +import org.ostelco.prime.paymentprocessor.core.NotFoundError as NotFoundPaymentError enum class Relation { - HAS_SUBSCRIPTION, // (Subscriber) -[HAS_SUBSCRIPTION]-> (Subscription) - HAS_BUNDLE, // (Subscriber) -[HAS_BUNDLE]-> (Bundle) - LINKED_TO_BUNDLE, // (Subscription) -[LINKED_TO_BUNDLE]-> (Bundle) - PURCHASED, // (Subscriber) -[PURCHASED]-> (Product) - REFERRED, // (Subscriber) -[REFERRED]-> (Subscriber) - OFFERED_TO_SEGMENT, // (Offer) -[OFFERED_TO_SEGMENT]-> (Segment) - OFFER_HAS_PRODUCT, // (Offer) -[OFFER_HAS_PRODUCT]-> (Product) - BELONG_TO_SEGMENT // (Subscriber) -[BELONG_TO_SEGMENT]-> (Segment) + IDENTIFIES, // (Identity) -[IDENTIFIES]-> (Customer) + HAS_SUBSCRIPTION, // (Customer) -[HAS_SUBSCRIPTION]-> (Subscription) + HAS_BUNDLE, // (Customer) -[HAS_BUNDLE]-> (Bundle) + HAS_SIM_PROFILE, // (Customer) -[HAS_SIM_PROFILE]-> (SimProfile) + SUBSCRIBES_TO_PLAN, // (Customer) -[SUBSCRIBES_TO_PLAN]-> (Plan) + HAS_PRODUCT, // (Plan) -[HAS_PRODUCT]-> (Product) + LINKED_TO_BUNDLE, // (Subscription) -[LINKED_TO_BUNDLE]-> (Bundle) + PURCHASED, // (Customer) -[PURCHASED]-> (Product) + REFERRED, // (Customer) -[REFERRED]-> (Customer) + OFFERED_TO_SEGMENT, // (Offer) -[OFFERED_TO_SEGMENT]-> (Segment) + OFFER_HAS_PRODUCT, // (Offer) -[OFFER_HAS_PRODUCT]-> (Product) + BELONG_TO_SEGMENT, // (Customer) -[BELONG_TO_SEGMENT]-> (Segment) + EKYC_SCAN, // (Customer) -[EKYC_SCAN]-> (ScanInformation) + BELONG_TO_REGION, // (Customer) -[BELONG_TO_REGION]-> (Region) + SUBSCRIPTION_FOR_REGION, // (Subscription) -[SUBSCRIPTION_FOR_REGION]-> (Region) + SIM_PROFILE_FOR_REGION, // (SimProfile) -[SIM_PROFILE_FOR_REGION]-> (Region) } @@ -57,15 +87,18 @@ class Neo4jStore : GraphStore by Neo4jStoreSingleton object Neo4jStoreSingleton : GraphStore { - private val ocsAdminService: OcsAdminService by lazy { getResource() } private val logger by getLogger() + private val scanInformationDatastore by lazy { getResource() } // // Entity // - private val subscriberEntity = EntityType(Subscriber::class.java) - private val subscriberStore = EntityStore(subscriberEntity) + private val identityEntity = EntityType(Identity::class.java) + private val identityStore = EntityStore(identityEntity) + + private val customerEntity = EntityType(Customer::class.java) + private val customerStore = EntityStore(customerEntity) private val productEntity = EntityType(Product::class.java) private val productStore = EntityStore(productEntity) @@ -76,55 +109,156 @@ object Neo4jStoreSingleton : GraphStore { private val bundleEntity = EntityType(Bundle::class.java) private val bundleStore = EntityStore(bundleEntity) + private val simProfileEntity = EntityType(SimProfile::class.java) + private val simProfileStore = EntityStore(simProfileEntity) + + private val planEntity = EntityType(Plan::class.java) + private val plansStore = EntityStore(planEntity) + + private val regionEntity = EntityType(Region::class.java) + private val regionStore = EntityStore(regionEntity) + + private val scanInformationEntity = EntityType(ScanInformation::class.java) + private val scanInformationStore = EntityStore(scanInformationEntity) + // // Relation // + private val identifiesRelation = RelationType( + relation = IDENTIFIES, + from = identityEntity, + to = customerEntity, + dataClass = Identifies::class.java) + private val identifiesRelationStore = RelationStore(identifiesRelation) + private val subscriptionRelation = RelationType( relation = HAS_SUBSCRIPTION, - from = subscriberEntity, + from = customerEntity, to = subscriptionEntity, - dataClass = Void::class.java) + dataClass = None::class.java) private val subscriptionRelationStore = RelationStore(subscriptionRelation) - private val subscriberToBundleRelation = RelationType( + private val customerToBundleRelation = RelationType( relation = HAS_BUNDLE, - from = subscriberEntity, + from = customerEntity, to = bundleEntity, - dataClass = Void::class.java) - private val subscriberToBundleStore = RelationStore(subscriberToBundleRelation) + dataClass = None::class.java) + private val customerToBundleStore = RelationStore(customerToBundleRelation) private val subscriptionToBundleRelation = RelationType( relation = LINKED_TO_BUNDLE, from = subscriptionEntity, to = bundleEntity, - dataClass = Void::class.java) + dataClass = SubscriptionToBundle::class.java) private val subscriptionToBundleStore = RelationStore(subscriptionToBundleRelation) + private val customerToSimProfileRelation = RelationType( + relation = HAS_SIM_PROFILE, + from = customerEntity, + to = simProfileEntity, + dataClass = None::class.java) + private val customerToSimProfileStore = RelationStore(customerToSimProfileRelation) + private val purchaseRecordRelation = RelationType( relation = PURCHASED, - from = subscriberEntity, + from = customerEntity, to = productEntity, dataClass = PurchaseRecord::class.java) private val purchaseRecordRelationStore = RelationStore(purchaseRecordRelation) + private val changablePurchaseRelationStore = ChangeableRelationStore(purchaseRecordRelation) private val referredRelation = RelationType( relation = REFERRED, - from = subscriberEntity, - to = subscriberEntity, - dataClass = Void::class.java) + from = customerEntity, + to = customerEntity, + dataClass = None::class.java) private val referredRelationStore = RelationStore(referredRelation) + private val subscribesToPlanRelation = RelationType( + relation = Relation.SUBSCRIBES_TO_PLAN, + from = customerEntity, + to = planEntity, + dataClass = PlanSubscription::class.java) + private val subscribesToPlanRelationStore = UniqueRelationStore(subscribesToPlanRelation) + + private val customerRegionRelation = RelationType( + relation = Relation.BELONG_TO_REGION, + from = customerEntity, + to = regionEntity, + dataClass = CustomerRegion::class.java) + private val customerRegionRelationStore = UniqueRelationStore(customerRegionRelation) + + private val scanInformationRelation = RelationType( + relation = Relation.EKYC_SCAN, + from = customerEntity, + to = scanInformationEntity, + dataClass = None::class.java) + private val scanInformationRelationStore = UniqueRelationStore(scanInformationRelation) + + private val planProductRelation = RelationType( + relation = Relation.HAS_PRODUCT, + from = planEntity, + to = productEntity, + dataClass = None::class.java) + private val planProductRelationStore = UniqueRelationStore(planProductRelation) + + private val subscriptionRegionRelation = RelationType( + relation = Relation.SUBSCRIPTION_FOR_REGION, + from = subscriptionEntity, + to = regionEntity, + dataClass = None::class.java) + private val subscriptionRegionRelationStore = UniqueRelationStore(subscriptionRegionRelation) + + private val simProfileRegionRelation = RelationType( + relation = Relation.SIM_PROFILE_FOR_REGION, + from = simProfileEntity, + to = regionEntity, + dataClass = None::class.java) + private val simProfileRegionRelationStore = UniqueRelationStore(simProfileRegionRelation) + // ------------- // Client Store // ------------- // - // Balance (Subscriber - Bundle) + // Identity + // + + override fun getCustomerId(identity: org.ostelco.prime.model.Identity): Either = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + } + + private fun getCustomerId(identity: org.ostelco.prime.model.Identity, transaction: Transaction): Either { + return identityStore.getRelated(id = identity.id, relationType = identifiesRelation, transaction = transaction) + .flatMap { + if (it.isEmpty()) { + NotFoundError(type = identity.type, id = identity.id).left() + } else { + it.single().id.right() + } + } + } + + private fun getCustomerAndAnalyticsId(identity: org.ostelco.prime.model.Identity, transaction: Transaction): Either> { + return identityStore.getRelated(id = identity.id, relationType = identifiesRelation, transaction = transaction) + .flatMap { + if (it.isEmpty()) { + NotFoundError(type = identity.type, id = identity.id).left() + } else { + val customer = it.single() + Pair(customer.id, customer.analyticsId).right() + } + } + } + + // + // Balance (Customer - Bundle) // - override fun getBundles(subscriberId: String): Either> = readTransaction { - subscriberStore.getRelated(subscriberId, subscriberToBundleRelation, transaction) + override fun getBundles(identity: org.ostelco.prime.model.Identity): Either> = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> customerStore.getRelated(customerId, customerToBundleRelation, transaction) } } override fun updateBundle(bundle: Bundle): Either = writeTransaction { @@ -133,181 +267,404 @@ object Neo4jStoreSingleton : GraphStore { } // - // Subscriber + // Customer // - override fun getSubscriber(subscriberId: String): Either = - readTransaction { subscriberStore.get(subscriberId, transaction) } + override fun getCustomer(identity: org.ostelco.prime.model.Identity): Either = readTransaction { + getCustomer(identity = identity, transaction = transaction) + } + + private fun getCustomer( + identity: org.ostelco.prime.model.Identity, + transaction: Transaction): Either = identityStore.getRelated( + id = identity.id, + relationType = identifiesRelation, + transaction = transaction) + .map(List::single) + + private fun validateCreateCustomerParams(customer: Customer, referredBy: String?): Either = + if (customer.referralId == referredBy) { + Either.left(ValidationError(type = customerEntity.name, id = customer.id, message = "Referred by self")) + } else { + Unit.right() + } // TODO vihang: Move this logic to DSL + Rule Engine + Triggers, when they are ready // >> BEGIN - override fun addSubscriber(subscriber: Subscriber, referredBy: String?): Either = writeTransaction { - - if (subscriber.id == referredBy) { - return@writeTransaction Either.left(ValidationError( - type = subscriberEntity.name, - id = subscriber.id, - message = "Referred by self")) - } - - val bundleId = subscriber.id - - val either = subscriberStore.create(subscriber, transaction) - .flatMap { - subscriberToSegmentStore - .create(subscriber.id, - getSegmentNameFromCountryCode(subscriber.country), - transaction) - .mapLeft { storeError -> - if (storeError is NotCreatedError && storeError.type == subscriberToSegmentRelation.relation.name) { - ValidationError( - type = subscriberEntity.name, - id = subscriber.id, - message = "Unsupported country: ${subscriber.country}") - } else { - storeError - } - } + override fun addCustomer(identity: ModelIdentity, customer: Customer, referredBy: String?): Either = writeTransaction { + // IO is used to represent operations that can be executed lazily and are capable of failing. + // Here it runs IO synchronously and returning its result blocking the current thread. + // https://arrow-kt.io/docs/patterns/monad_comprehensions/#comprehensions-over-coroutines + // https://arrow-kt.io/docs/effects/io/#unsaferunsync + IO { + Either.monad().binding { + validateCreateCustomerParams(customer, referredBy).bind() + val bundleId = UUID.randomUUID().toString() + identityStore.create(Identity(id = identity.id, type = identity.type), transaction).bind() + customerStore.create(customer, transaction).bind() + identifiesRelationStore.create(fromId = identity.id, relation = Identifies(provider = identity.provider), toId = customer.id, transaction = transaction).bind() + // Give 100 MB as free initial balance + val productId = "2GB_FREE_ON_JOINING" + val balance: Long = 2_147_483_648 + if (referredBy != null) { + referredRelationStore.create(referredBy, customer.id, transaction).bind() } - - if (referredBy != null) { - // Give 1 GB if subscriber is referred - either - .flatMap { referredRelationStore.create(referredBy, subscriber.id, transaction) } - .flatMap { bundleStore.create(Bundle(bundleId, 1_000_000_000), transaction) } - .flatMap { _ -> - productStore - .get("1GB_FREE_ON_REFERRED", transaction) - .flatMap { - createPurchaseRecordRelation( - subscriber.id, - PurchaseRecord(id = UUID.randomUUID().toString(), product = it, timestamp = Instant.now().toEpochMilli(), msisdn = ""), - transaction) - } - } - .flatMap { - ocsAdminService.addBundle(Bundle(bundleId, 1_000_000_000)) - Either.right(Unit) - } - } else { - // Give 100 MB as free initial balance - either - .flatMap { bundleStore.create(Bundle(bundleId, 100_000_000), transaction) } - .flatMap { _ -> - productStore - .get("100MB_FREE_ON_JOINING", transaction) - .flatMap { - createPurchaseRecordRelation( - subscriber.id, - PurchaseRecord(id = UUID.randomUUID().toString(), product = it, timestamp = Instant.now().toEpochMilli(), msisdn = ""), - transaction) - } - } - .flatMap { - ocsAdminService.addBundle(Bundle(bundleId, 100_000_000)) - Either.right(Unit) - } - }.flatMap { subscriberToBundleStore.create(subscriber.id, bundleId, transaction) - }.map { - if(subscriber.country.equals("sg", ignoreCase = true)) { - logger.info(NOTIFY_OPS_MARKER, "Created a new user with email: ${subscriber.email} for Singapore.\nProvision a SIM card for this user.") - } - }.ifFailedThenRollback(transaction) + bundleStore.create(Bundle(bundleId, balance), transaction).bind() + val product = productStore.get(productId, transaction).bind() + createPurchaseRecordRelation( + customer.id, + PurchaseRecord(id = UUID.randomUUID().toString(), product = product, timestamp = Instant.now().toEpochMilli()), + transaction) + customerToBundleStore.create(customer.id, bundleId, transaction).bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) } // << END - override fun updateSubscriber(subscriber: Subscriber): Either = writeTransaction { - subscriberStore.update(subscriber, transaction) + override fun updateCustomer( + identity: org.ostelco.prime.model.Identity, + nickname: String?, + contactEmail: String?): Either = writeTransaction { + + getCustomer(identity = identity, transaction = transaction) + .flatMap { existingCustomer -> + customerStore.update( + existingCustomer.copy( + nickname = nickname ?: existingCustomer.nickname, + contactEmail = contactEmail ?: existingCustomer.contactEmail), + transaction) + } .ifFailedThenRollback(transaction) } - override fun removeSubscriber(subscriberId: String): Either = writeTransaction { - subscriberStore.exists(subscriberId, transaction) - .flatMap { _ -> - subscriberStore.getRelated(subscriberId, subscriberToBundleRelation, transaction) - .map { it.forEach { bundle -> bundleStore.delete(bundle.id, transaction) } } - subscriberStore.getRelated(subscriberId, subscriptionRelation, transaction) - .map { it.forEach { subscription -> subscriptionStore.delete(subscription.id, transaction) } } + // TODO vihang: Should we also delete SimProfile attached to this user? + override fun removeCustomer(identity: org.ostelco.prime.model.Identity): Either = writeTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + identityStore.delete(id = identity.id, transaction = transaction) + customerStore.exists(customerId, transaction) + .flatMap { + customerStore.getRelated(customerId, customerToBundleRelation, transaction) + .map { it.forEach { bundle -> bundleStore.delete(bundle.id, transaction) } } + customerStore.getRelated(customerId, subscriptionRelation, transaction) + .map { it.forEach { subscription -> subscriptionStore.delete(subscription.id, transaction) } } + customerStore.getRelated(customerId, scanInformationRelation, transaction) + .map { it.forEach { scanInfo -> scanInformationStore.delete(scanInfo.id, transaction) } } + } + .flatMap { customerStore.delete(customerId, transaction) } } - .flatMap { subscriberStore.delete(subscriberId, transaction) } .ifFailedThenRollback(transaction) } // - // Subscription + // Customer Region // - override fun addSubscription(subscriberId: String, msisdn: String): Either = writeTransaction { + override fun getAllRegionDetails(identity: org.ostelco.prime.model.Identity): Either> = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + getRegionDetails( + customerId = customerId, + transaction = transaction).right() + } + } - subscriberStore.getRelated(subscriberId, subscriberToBundleRelation, transaction) - .flatMap { bundles -> - if (bundles.isEmpty()) { - Either.left(NotFoundError(type = subscriberToBundleRelation.relation.name, id = "$subscriberId -> *")) - } else { - Either.right(bundles) - } + override fun getRegionDetails( + identity: org.ostelco.prime.model.Identity, + regionCode: String): Either = readTransaction { + + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + getRegionDetails( + customerId = customerId, + regionCode = regionCode, + transaction = transaction) + .singleOrNull() + ?.right() + ?: NotFoundError(type = customerRegionRelation.name, id = "$customerId -> $regionCode").left() } - .flatMap { bundles -> - subscriptionStore.create(Subscription(msisdn), transaction) - .map { bundles } - } - .flatMap { bundles -> - subscriptionStore.get(msisdn, transaction) - .map { subscription -> Pair(bundles, subscription) } - } - .flatMap { (bundles, subscription) -> - subscriberStore.get(subscriberId, transaction) - .map { subscriber -> Triple(bundles, subscription, subscriber) } - } - .flatMap { (bundles, subscription, subscriber) -> - bundles.fold(Either.right(Unit) as Either) { either, bundle -> - either.flatMap { _ -> - subscriptionToBundleStore.create(subscription, bundle, transaction) - .flatMap { - ocsAdminService.addMsisdnToBundleMapping(msisdn, bundle.id) - Either.right(Unit) + } + + private fun getRegionDetails( + customerId: String, + regionCode: String? = null, + transaction: Transaction): Collection { + + val regionCodeClause = regionCode?.let { "{id: '$it'}" } ?: "" + + return read(""" + MATCH (c:${customerEntity.name} {id: '$customerId'})-[cr:${customerRegionRelation.name}]->(r:${regionEntity.name} $regionCodeClause) + OPTIONAL MATCH (c)-[:${customerToSimProfileRelation.name}]->(sp:${simProfileEntity.name})-[:${simProfileRegionRelation.name}]->(r) + RETURN cr, r, sp; + """.trimIndent(), + transaction) { statementResult -> + statementResult + .list { record -> + val region = regionEntity.createEntity(record["r"].asMap()) + val cr = customerRegionRelation.createRelation(record["cr"].asMap()) + val simProfiles = if (record["sp"].isNull) { + emptyList() + } else { + listOf(simProfileEntity.createEntity(record["sp"].asMap())) + .mapNotNull { simProfile -> + simManager.getSimProfile( + hlr = getHlr(regionCode = region.id), + iccId = simProfile.iccId) + .map { simEntry -> + org.ostelco.prime.model.SimProfile( + iccId = simProfile.iccId, + status = simEntry.status, + eSimActivationCode = simEntry.eSimActivationCode, + alias = simProfile.alias) + } + .fold( + { error -> + logger.error("Failed to fetch SIM Profile: {} for region: {}. Reason: {}", + simProfile.iccId, + region.id, + error) + null + }, + { it }) } } - }.map { Pair(subscription, subscriber) } + RegionDetails( + region = region, + status = cr.status, + kycStatusMap = cr.kycStatusMap, + simProfiles = simProfiles) + } + .requireNoNulls() + .groupBy { RegionDetails(region = it.region, status = it.status, kycStatusMap = it.kycStatusMap) } + .map { (key, value) -> + RegionDetails( + region = key.region, + status = key.status, + kycStatusMap = key.kycStatusMap, + simProfiles = value.flatMap(RegionDetails::simProfiles)) + } + } + } + + // + // SIM Profile + // + + private val simManager by lazy { getResource() } + + private val emailNotifier by lazy { getResource() } + + private fun validateBundleList(bundles: List, customerId: String): Either = + if (bundles.isEmpty()) { + Either.left(NotFoundError(type = customerToBundleRelation.name, id = "$customerId -> *")) + } else { + Unit.right() + } + + override fun provisionSimProfile( + identity: org.ostelco.prime.model.Identity, + regionCode: String, + profileType: String?): Either = writeTransaction { + IO { + Either.monad().binding { + val customerId = getCustomerId(identity = identity, transaction = transaction).bind() + val bundles = customerStore.getRelated(customerId, customerToBundleRelation, transaction).bind() + validateBundleList(bundles, customerId).bind() + val customer = customerStore.get(customerId, transaction).bind() + val status = customerRegionRelationStore + .get(fromId = customerId, toId = regionCode.toLowerCase(), transaction = transaction) + .bind() + .status + isApproved( + status = status, + customerId = customerId, + regionCode = regionCode.toLowerCase()).bind() + val region = regionStore.get(id = regionCode.toLowerCase(), transaction = transaction).bind() + val simEntry = simManager.allocateNextEsimProfile(hlr = getHlr(region.id.toLowerCase()), phoneType = profileType) + .mapLeft { NotFoundError("eSIM profile", id = "Loltel") } + .bind() + val simProfile = SimProfile(id = UUID.randomUUID().toString(), iccId = simEntry.iccId) + simProfileStore.create( + entity = simProfile, + transaction = transaction).bind() + customerToSimProfileStore.create( + fromId = customerId, + toId = simProfile.id, + transaction = transaction).bind() + simProfileRegionRelationStore.create( + fromId = simProfile.id, + toId = regionCode.toLowerCase(), + transaction = transaction).bind() + simEntry.msisdnList.forEach { msisdn -> + subscriptionStore.create(Subscription(msisdn = msisdn), transaction).bind() + val subscription = subscriptionStore.get(msisdn, transaction).bind() + bundles.forEach { bundle -> + subscriptionToBundleStore.create( + from = subscription, + relation = SubscriptionToBundle(), + to = bundle, + transaction = transaction).bind() + } + subscriptionRelationStore.create(customer, subscription, transaction).bind() + subscriptionRegionRelationStore.create( + fromId = msisdn, + toId = regionCode.toLowerCase(), + transaction = transaction).bind() + // TODO vihang: link SimProfile to Subscription and unlink Subscription from Region } - .flatMap { (subscription, subscriber) -> - subscriptionRelationStore.create(subscriber, subscription, transaction) + if (profileType != "android") { + emailNotifier.sendESimQrCodeEmail( + email = customer.contactEmail, + name = customer.nickname, + qrCode = simEntry.eSimActivationCode) + .mapLeft { + logger.error(NOTIFY_OPS_MARKER, "Failed to send email to {}", customer.contactEmail) + } } + org.ostelco.prime.model.SimProfile( + iccId = simEntry.iccId, + alias = "", + eSimActivationCode = simEntry.eSimActivationCode, + status = simEntry.status) + }.fix() + }.unsafeRunSync() .ifFailedThenRollback(transaction) } - override fun getSubscriptions(subscriberId: String): Either> = - readTransaction { subscriberStore.getRelated(subscriberId, subscriptionRelation, transaction) } + private fun isApproved( + status: CustomerRegionStatus, + customerId: String, + regionCode: String): Either { + + return if (status != APPROVED) { + ValidationError( + type = "customerRegionRelation", + id = "$customerId -> $regionCode", + message = "eKYC status is $status and not APPROVED.") + .left() + } else { + Unit.right() + } + } - override fun getMsisdn(subscriptionId: String): Either { - return readTransaction { - subscriberStore.getRelated(subscriptionId, subscriptionRelation, transaction) - .flatMap { - if (it.isEmpty()) { - Either.left(NotFoundError( - type = subscriptionEntity.name, - id = "for ${subscriberEntity.name} = $subscriptionId")) - } else { - Either.right(it.first().msisdn) + override fun getSimProfiles( + identity: org.ostelco.prime.model.Identity, + regionCode: String?): Either> { + + val map = mutableMapOf() + val simProfiles = readTransaction { + IO { + Either.monad().binding { + + val customerId = getCustomerId(identity = identity, transaction = transaction).bind() + val simProfiles = customerStore.getRelated( + id = customerId, + relationType = customerToSimProfileRelation, + transaction = transaction).bind() + if (regionCode == null) { + simProfiles.forEach { simProfile -> + val region = simProfileStore.getRelated( + id = simProfile.id, + relationType = simProfileRegionRelation, + transaction = transaction) + .bind() + .first() + map[simProfile.id] = region.id } } + simProfiles + }.fix() + }.unsafeRunSync() } + + return IO { + Either.monad() + .binding { + simProfiles.bind().map { simProfile -> + val regionId = (regionCode ?: map[simProfile.id]) + ?: ValidationError(type = simProfileEntity.name, id = simProfile.iccId, message = "SimProfile not linked to any region") + .left() + .bind() + + val simEntry = simManager.getSimProfile( + hlr = getHlr(regionId), + iccId = simProfile.iccId) + .mapLeft { NotFoundError(type = simProfileEntity.name, id = simProfile.iccId) } + .bind() + org.ostelco.prime.model.SimProfile( + iccId = simProfile.iccId, + alias = simProfile.alias, + eSimActivationCode = simEntry.eSimActivationCode, + status = simEntry.status) + } + }.fix() + }.unsafeRunSync() + } + + private fun getHlr(regionCode: String): String { + return "Loltel" + } + + // + // Subscription + // + + @Deprecated(message = "Use createSubscriptions instead") + override fun addSubscription(identity: org.ostelco.prime.model.Identity, msisdn: String): Either = writeTransaction { + IO { + Either.monad().binding { + val customerId = Neo4jStoreSingleton.getCustomerId(identity = identity, transaction = transaction).bind() + val bundles = customerStore.getRelated(customerId, customerToBundleRelation, transaction).bind() + validateBundleList(bundles, customerId).bind() + subscriptionStore.create(Subscription(msisdn), transaction).bind() + val subscription = subscriptionStore.get(msisdn, transaction).bind() + val customer = customerStore.get(customerId, transaction).bind() + bundles.forEach { bundle -> + subscriptionToBundleStore.create(subscription, SubscriptionToBundle(), bundle, transaction).bind() + } + subscriptionRelationStore.create(customer, subscription, transaction).bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + + override fun getSubscriptions(identity: org.ostelco.prime.model.Identity, regionCode: String?): Either> = readTransaction { + IO { + Either.monad().binding { + val customerId = getCustomerId(identity = identity, transaction = transaction).bind() + if (regionCode == null) { + customerStore.getRelated(customerId, subscriptionRelation, transaction).bind() + } else { + read>>(""" + MATCH (:${customerEntity.name} {id: '$customerId'}) + -[:${subscriptionRelation.name}]->(sn:${subscriptionEntity.name}) + -[:${subscriptionRegionRelation.name}]->(:${regionEntity.name} {id: '${regionCode.toLowerCase()}'}) + RETURN sn; + """.trimIndent(), + transaction) { statementResult -> + Either.right(statementResult + .list { subscriptionEntity.createEntity(it["sn"].asMap()) }) + }.bind() + } + }.fix() + }.unsafeRunSync() } // // Products // - override fun getProducts(subscriberId: String): Either> { + override fun getProducts(identity: org.ostelco.prime.model.Identity): Either> { return readTransaction { - subscriberStore.exists(subscriberId, transaction) - .flatMap { _ -> + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> read>>(""" - MATCH (:${subscriberEntity.name} {id: '$subscriberId'}) - -[:${subscriberToSegmentRelation.relation.name}]->(:${segmentEntity.name}) - <-[:${offerToSegmentRelation.relation.name}]-(:${offerEntity.name}) - -[:${offerToProductRelation.relation.name}]->(product:${productEntity.name}) + MATCH (:${customerEntity.name} {id: '$customerId'}) + -[:${customerToSegmentRelation.name}]->(:${segmentEntity.name}) + <-[:${offerToSegmentRelation.name}]-(:${offerEntity.name}) + -[:${offerToProductRelation.name}]->(product:${productEntity.name}) RETURN product; """.trimIndent(), transaction) { statementResult -> @@ -320,20 +677,20 @@ object Neo4jStoreSingleton : GraphStore { } } - override fun getProduct(subscriberId: String, sku: String): Either { + override fun getProduct(identity: org.ostelco.prime.model.Identity, sku: String): Either { return readTransaction { - getProduct(subscriberId, sku, transaction) + getProduct(identity, sku, transaction) } } - private fun getProduct(subscriberId: String, sku: String, transaction: Transaction): Either { - return subscriberStore.exists(subscriberId, transaction) - .flatMap { + private fun getProduct(identity: org.ostelco.prime.model.Identity, sku: String, transaction: Transaction): Either { + return getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> read(""" - MATCH (:${subscriberEntity.name} {id: '$subscriberId'}) - -[:${subscriberToSegmentRelation.relation.name}]->(:${segmentEntity.name}) - <-[:${offerToSegmentRelation.relation.name}]-(:${offerEntity.name}) - -[:${offerToProductRelation.relation.name}]->(product:${productEntity.name} {sku: '$sku'}) + MATCH (:${customerEntity.name} {id: '$customerId'}) + -[:${customerToSegmentRelation.name}]->(:${segmentEntity.name}) + <-[:${offerToSegmentRelation.name}]-(:${offerEntity.name}) + -[:${offerToProductRelation.name}]->(product:${productEntity.name} {sku: '$sku'}) RETURN product; """.trimIndent(), transaction) { statementResult -> @@ -347,117 +704,258 @@ object Neo4jStoreSingleton : GraphStore { } // - // Purchase Records + // Consumption + // + + /** + * This method takes [msisdn], [usedBytes] and [requestedBytes] as parameters. + * The [usedBytes] will then be deducted from existing `balance` and `reservedBytes` from a [Bundle] associated with + * this [msisdn]. + * Thus, `reservedBytes` is set back to `zero` and `surplus/deficit` bytes are adjusted with main `balance`. + * Then, bytes equal to or less than [requestedBytes] are deducted from `balance` such that `balance` is `non-negative`. + * Those bytes are then set as `reservedBytes` and returned as response. + * + * The above logic is vanilla case and may be enriched based on multiple factors such as mcc_mnc, rating-group etc. + * + * @param msisdn which is consuming + * @param usedBytes Bytes already consumed. + * @param requestedBytes Bytes requested for consumption. + * + */ + override suspend fun consume(msisdn: String, usedBytes: Long, requestedBytes: Long, callback: (Either) -> Unit) { + + // Note: _LOCK_ dummy property is set in the relation 'r' and node 'bundle' so that they get locked. + // Ref: https://neo4j.com/docs/java-reference/current/transactions/#transactions-isolation + + suspendedWriteTransaction { + + writeSuspended(""" + MATCH (sn:${subscriptionEntity.name} {id: '$msisdn'})-[r:${subscriptionToBundleRelation.name}]->(bundle:${bundleEntity.name}) + SET bundle._LOCK_ = true, r._LOCK_ = true + WITH r, bundle, sn.analyticsId AS msisdnAnalyticsId, (CASE WHEN ((toInteger(bundle.balance) + toInteger(r.reservedBytes) - $usedBytes) > 0) THEN (toInteger(bundle.balance) + toInteger(r.reservedBytes) - $usedBytes) ELSE 0 END) AS tmpBalance + WITH r, bundle, msisdnAnalyticsId, tmpBalance, (CASE WHEN (tmpBalance < $requestedBytes) THEN tmpBalance ELSE $requestedBytes END) as tmpGranted + SET r.reservedBytes = toString(tmpGranted), bundle.balance = toString(tmpBalance - tmpGranted) + REMOVE r._LOCK_, bundle._LOCK_ + RETURN msisdnAnalyticsId, r.reservedBytes AS granted, bundle.balance AS balance + """.trimIndent(), + transaction) { completionStage -> + completionStage + .thenApply { it.singleAsync() } + .thenAcceptAsync { + it.handle { record, throwable -> + + if (throwable != null) { + callback(NotUpdatedError(type = "Balance for ${subscriptionEntity.name}", id = msisdn).left()) + } else { + val balance = record.get("balance").asString("0").toLong() + val granted = record.get("granted").asString("0").toLong() + val msisdnAnalyticsId = record.get("msisdnAnalyticsId").asString(msisdn) + + logger.trace("requestedBytes = %,d, balance = %,d, granted = %,d".format(requestedBytes, balance, granted)) + callback(ConsumptionResult(msisdnAnalyticsId = msisdnAnalyticsId, granted = granted, balance = balance).right()) + } + } + } + } + } + } + + // + // Purchase // // TODO vihang: Move this logic to DSL + Rule Engine + Triggers, when they are ready // >> BEGIN private val paymentProcessor by lazy { getResource() } - private val ocs by lazy { getResource() } private val analyticsReporter by lazy { getResource() } + private fun fetchOrCreatePaymentProfile(customer: Customer): Either = + // Fetch/Create stripe payment profile for the customer. + paymentProcessor.getPaymentProfile(customer.id) + .fold( + { paymentProcessor.createPaymentProfile(customerId = customer.id, + email = customer.contactEmail) }, + { profileInfo -> Either.right(profileInfo) } + ) + override fun purchaseProduct( - subscriberId: String, + identity: org.ostelco.prime.model.Identity, sku: String, sourceId: String?, - saveCard: Boolean): Either = writeTransaction { - - getProduct(subscriberId, sku, transaction) - // If we can't find the product, return not-found - .mapLeft { org.ostelco.prime.paymentprocessor.core.NotFoundError("Product unavailable") } - .flatMap { product: Product -> - // Fetch/Create stripe payment profile for the subscriber. - paymentProcessor.getPaymentProfile(subscriberId) - .fold( - { paymentProcessor.createPaymentProfile(subscriberId) }, - { profileInfo -> Either.right(profileInfo) } - ) - .map { profileInfo -> Pair(product, profileInfo.id) } - } - .flatMap { (product, paymentCustomerId) -> - // Add payment source - if (sourceId != null) { - // First fetch all existing saved sources - paymentProcessor.getSavedSources(paymentCustomerId) - .fold( - { - Either.left(org.ostelco.prime.paymentprocessor.core.BadGatewayError("Failed to fetch sources for user", it.description)) - }, - { - // If the sourceId is not found in existing list of saved sources, - // then save the source - if (!it.any { sourceDetailsInfo -> sourceDetailsInfo.id == sourceId }) { - paymentProcessor.addSource(paymentCustomerId, sourceId) - // For success case, saved source is removed after "capture charge" is saveCard == false. - // Making sure same happens even for failure case by linking reversal action to transaction - .finallyDo(transaction) { _ -> removePaymentSource(saveCard, paymentCustomerId, sourceId) } - .map { sourceInfo -> Triple(product, paymentCustomerId, sourceInfo.id) } - } else { - Either.right(Triple(product, paymentCustomerId, sourceId)) - } - } - ) + saveCard: Boolean): Either { + + return getProduct(identity, sku).fold( + { + Either.left(org.ostelco.prime.paymentprocessor.core.NotFoundError("Product $sku is unavailable", + error = it)) + }, + { + /* TODO: Complete support for 'product-class' and store plans as a + 'product' of product-class: 'plan'. */ + return if (it.properties.containsKey("productType") + && it.properties["productType"].equals("plan", true)) { + + purchasePlan( + identity = identity, + product = it, + sourceId = sourceId, + saveCard = saveCard) } else { - Either.right(Triple(product, paymentCustomerId, null)) + + purchaseProduct( + identity = identity, + product = it, + sourceId = sourceId, + saveCard = saveCard) } } - .flatMap { (product, paymentCustomerId, sourceId) -> - // Authorize stripe charge for this purchase - val price = product.price - //TODO: If later steps fail, then refund the authorized charge - paymentProcessor.authorizeCharge(paymentCustomerId, sourceId, price.amount, price.currency) - .mapLeft { apiError -> - logger.error("failed to authorize purchase for paymentCustomerId $paymentCustomerId, sourceId $sourceId, sku $sku") - apiError - } - .linkReversalActionToTransaction(transaction) { chargeId -> - paymentProcessor.refundCharge(chargeId) - logger.error(NOTIFY_OPS_MARKER, "Failed to refund charge for paymentCustomerId $paymentCustomerId, chargeId $chargeId.\nFix this in Stripe dashboard.") - } - .map { chargeId -> Tuple3(product, paymentCustomerId, chargeId) } - } - .flatMap { (product, paymentCustomerId, chargeId) -> - val purchaseRecord = PurchaseRecord( - id = chargeId, - product = product, - timestamp = Instant.now().toEpochMilli(), - msisdn = "") - // Create purchase record - createPurchaseRecordRelation(subscriberId, purchaseRecord, transaction) - .mapLeft { storeError -> - logger.error("failed to save purchase record, for paymentCustomerId $paymentCustomerId, chargeId $chargeId, payment will be unclaimed in Stripe") - BadGatewayError(storeError.message) - } - .flatMap { - //TODO: While aborting transactions, send a record with "reverted" status - analyticsReporter.reportPurchaseInfo( - purchaseRecord = purchaseRecord, - subscriberId = subscriberId, - status = "success") + ) + } - Either.right(Tuple3(product, paymentCustomerId, chargeId)) - } - } - .flatMap { (product, paymentCustomerId, chargeId) -> - // Notify OCS - ocs.topup(subscriberId, sku) - .bimap({ BadGatewayError(description = "Failed to perform topup", externalErrorMessage = it) }, - { Tuple3(product, paymentCustomerId, chargeId) }) - } - .map { (product, paymentCustomerId, chargeId) -> + private fun purchasePlan(identity: org.ostelco.prime.model.Identity, + product: Product, + sourceId: String?, + saveCard: Boolean): Either = writeTransaction { + IO { + Either.monad().binding { + val customer = getCustomer(identity = identity, transaction = transaction) + .mapLeft { + org.ostelco.prime.paymentprocessor.core.NotFoundError( + "Failed to get customer data for customer with identity - $identity", + error = it) + } + .bind() + val profileInfo = fetchOrCreatePaymentProfile(customer) + .bind() + val paymentCustomerId = profileInfo.id - // Even if the "capture charge operation" failed, we do not want to rollback. - // In that case, we just want to log it at error level. - // These transactions can then me manually changed before they are auto rollback'ed in 'X' days. - paymentProcessor.captureCharge(chargeId, paymentCustomerId) + if (sourceId != null) { + val sourceDetails = paymentProcessor.getSavedSources(paymentCustomerId) .mapLeft { - // TODO payment: retry capture charge - logger.error(NOTIFY_OPS_MARKER, "Capture failed for paymentCustomerId $paymentCustomerId, chargeId $chargeId.\nFix this in Stripe Dashboard") - } + BadGatewayError("Failed to fetch sources for customer: ${customer.id}", + error = it) + }.bind() + if (!sourceDetails.any { sourceDetailsInfo -> sourceDetailsInfo.id == sourceId }) { + paymentProcessor.addSource(paymentCustomerId, sourceId) + .finallyDo(transaction) { + removePaymentSource(saveCard, paymentCustomerId, it.id) + }.bind().id + } + } + subscribeToPlan(identity, product.id) + .mapLeft { + org.ostelco.prime.paymentprocessor.core.BadGatewayError("Failed to subscribe ${customer.id} to plan ${product.id}", + error = it) + } + .flatMap { + Either.right(ProductInfo(product.id)) + }.bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } - // Ignore failure to capture charge and always send Either.right() - ProductInfo(product.sku) + private fun purchaseProduct(identity: org.ostelco.prime.model.Identity, + product: Product, + sourceId: String?, + saveCard: Boolean): Either = writeTransaction { + IO { + Either.monad().binding { + + val (customerId, customerAnalyticsId) = getCustomerAndAnalyticsId(identity = identity, transaction = transaction) + .mapLeft { + org.ostelco.prime.paymentprocessor.core.NotFoundError( + "Failed to get customerId for customer with identity - $identity", + error = it) + } + .bind() + val customer = getCustomer(identity = identity, transaction = transaction) + .mapLeft { + org.ostelco.prime.paymentprocessor.core.NotFoundError( + "Failed to get customer data for customer with identity - $identity", + error = it) + } + .bind() + val profileInfo = fetchOrCreatePaymentProfile(customer).bind() + val paymentCustomerId = profileInfo.id + var addedSourceId: String? = null + if (sourceId != null) { + // First fetch all existing saved sources + val sourceDetails = paymentProcessor.getSavedSources(paymentCustomerId) + .mapLeft { + BadGatewayError("Failed to fetch sources for user", + error = it) + }.bind() + addedSourceId = sourceId + // If the sourceId is not found in existing list of saved sources, + // then save the source + if (!sourceDetails.any { sourceDetailsInfo -> sourceDetailsInfo.id == sourceId }) { + addedSourceId = paymentProcessor.addSource(paymentCustomerId, sourceId) + // For success case, saved source is removed after "capture charge" is saveCard == false. + // Making sure same happens even for failure case by linking reversal action to transaction + .finallyDo(transaction) { removePaymentSource(saveCard, paymentCustomerId, it.id) } + .bind().id + } + } + //TODO: If later steps fail, then refund the authorized charge + val chargeId = paymentProcessor.authorizeCharge(paymentCustomerId, addedSourceId, product.price.amount, product.price.currency) + .mapLeft { + logger.error("Failed to authorize purchase for paymentCustomerId $paymentCustomerId, sourceId $addedSourceId, sku ${product.sku}") + it + }.linkReversalActionToTransaction(transaction) { chargeId -> + paymentProcessor.refundCharge(chargeId, product.price.amount, product.price.currency) + logger.error(NOTIFY_OPS_MARKER, + "Failed to refund charge for paymentCustomerId $paymentCustomerId, chargeId $chargeId.\nFix this in Stripe dashboard.") + }.bind() + + val purchaseRecord = PurchaseRecord( + id = chargeId, + product = product, + timestamp = Instant.now().toEpochMilli()) + createPurchaseRecordRelation(customerId, purchaseRecord, transaction) + .mapLeft { + logger.error("Failed to save purchase record, for paymentCustomerId $paymentCustomerId, chargeId $chargeId, payment will be unclaimed in Stripe") + BadGatewayError("Failed to save purchase record", + error = it) + }.bind() + + //TODO: While aborting transactions, send a record with "reverted" status + analyticsReporter.reportPurchaseInfo(purchaseRecord = purchaseRecord, customerAnalyticsId = customerAnalyticsId, status = "success") + + // Topup + val bytes = product.properties["noOfBytes"]?.replace("_", "")?.toLongOrNull() ?: 0L + + if (bytes == 0L) { + logger.error("Product with 0 bytes: sku = ${product.sku}") } + + write(""" + MATCH (cr:${customerEntity.name} { id:'$customerId' })-[:${customerToBundleRelation.name}]->(bundle:${bundleEntity.name}) + SET bundle.balance = toString(toInteger(bundle.balance) + $bytes) + """.trimIndent(), transaction) { + Either.cond( + test = it.summary().counters().containsUpdates(), + ifTrue = {}, + ifFalse = { + logger.error("Failed to update balance during purchase for customer: $customerId") + BadGatewayError( + description = "Failed to update balance during purchase for customer: $customerId", + message = "Failed to perform topup") + }) + }.bind() + + // Even if the "capture charge operation" failed, we do not want to rollback. + // In that case, we just want to log it at error level. + // These transactions can then me manually changed before they are auto rollback'ed in 'X' days. + paymentProcessor.captureCharge(chargeId, paymentCustomerId, product.price.amount, product.price.currency) + .mapLeft { + // TODO payment: retry capture charge + logger.error(NOTIFY_OPS_MARKER, "Capture failed for paymentCustomerId $paymentCustomerId, chargeId $chargeId.\nFix this in Stripe Dashboard") + } + // Ignore failure to capture charge, by not calling bind() + ProductInfo(product.sku) + }.fix() + }.unsafeRunSync() .ifFailedThenRollback(transaction) } // << END @@ -467,34 +965,39 @@ object Neo4jStoreSingleton : GraphStore { // These saved sources can then me manually removed. if (!saveCard) { paymentProcessor.removeSource(paymentCustomerId, sourceId) - .mapLeft { paymentError -> + .mapLeft { logger.error("Failed to remove card, for customerId $paymentCustomerId, sourceId $sourceId") - paymentError + it } } } - override fun getPurchaseRecords(subscriberId: String): Either> { + // + // Purchase Records + // + + override fun getPurchaseRecords(identity: org.ostelco.prime.model.Identity): Either> { return readTransaction { - subscriberStore.getRelations(subscriberId, purchaseRecordRelation, transaction) + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> customerStore.getRelations(customerId, purchaseRecordRelation, transaction) } } } - override fun addPurchaseRecord(subscriberId: String, purchase: PurchaseRecord): Either { + override fun addPurchaseRecord(customerId: String, purchase: PurchaseRecord): Either { return writeTransaction { - createPurchaseRecordRelation(subscriberId, purchase, transaction) + createPurchaseRecordRelation(customerId, purchase, transaction) .ifFailedThenRollback(transaction) } } private fun createPurchaseRecordRelation( - subscriberId: String, + customerId: String, purchase: PurchaseRecord, transaction: Transaction): Either { - return subscriberStore.get(subscriberId, transaction).flatMap { subscriber -> + return customerStore.get(customerId, transaction).flatMap { customer -> productStore.get(purchase.product.sku, transaction).flatMap { product -> - purchaseRecordRelationStore.create(subscriber, purchase, product, transaction) + purchaseRecordRelationStore.create(customer, purchase, product, transaction) .map { purchase.id } } } @@ -504,14 +1007,378 @@ object Neo4jStoreSingleton : GraphStore { // Referrals // - override fun getReferrals(subscriberId: String): Either> = readTransaction { - subscriberStore.getRelated(subscriberId, referredRelation, transaction) - .map { list -> list.map { it.name } } + override fun getReferrals(identity: org.ostelco.prime.model.Identity): Either> = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + customerStore.getRelated(customerId, referredRelation, transaction) + .map { list -> list.map { it.nickname } } + } + } + + override fun getReferredBy(identity: org.ostelco.prime.model.Identity): Either = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + customerStore.getRelatedFrom(customerId, referredRelation, transaction) + .map { it.singleOrNull()?.nickname } + } } - override fun getReferredBy(subscriberId: String): Either = readTransaction { - subscriberStore.getRelatedFrom(subscriberId, referredRelation, transaction) - .map { it.singleOrNull()?.name } + internal fun createCustomerRegionSetting( + customerId: String, + status: CustomerRegionStatus, + regionCode: String): Either = writeTransaction { + + createCustomerRegionSetting( + customerId = customerId, + status = status, + regionCode = regionCode, + transaction = transaction) + } + + private fun createCustomerRegionSetting( + customerId: String, + status: CustomerRegionStatus, + regionCode: String, + transaction: PrimeTransaction): Either = + + customerRegionRelationStore + .createIfAbsent( + fromId = customerId, + relation = CustomerRegion( + status = status, + kycStatusMap = getKycStatusMapForRegion(regionCode)), + toId = regionCode, + transaction = transaction) + .flatMap { + if (status == APPROVED) { + assignCustomerToRegionSegment( + customerId = customerId, + regionCode = regionCode, + transaction = transaction) + } else { + Unit.right() + } + } + + private fun assignCustomerToRegionSegment( + customerId: String, + regionCode: String, + transaction: Transaction): Either { + + return customerToSegmentStore.create( + fromId = customerId, + toId = getSegmentNameFromCountryCode(regionCode), + transaction = transaction).mapLeft { storeError -> + + if (storeError is NotCreatedError && storeError.type == customerToSegmentRelation.name) { + ValidationError(type = customerEntity.name, id = customerId, message = "Unsupported region: $regionCode") + } else { + storeError + } + } + } + + // + // eKYC - Jumio + // + + override fun createNewJumioKycScanId( + identity: org.ostelco.prime.model.Identity, + regionCode: String): Either = writeTransaction { + + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + // Generate new id for the scan + val scanId = UUID.randomUUID().toString() + val newScan = ScanInformation(scanId = scanId, countryCode = regionCode, status = ScanStatus.PENDING, scanResult = null) + createCustomerRegionSetting( + customerId = customerId, status = PENDING, regionCode = regionCode.toLowerCase(), transaction = transaction) + .flatMap { + scanInformationStore.create(newScan, transaction) + } + .flatMap { + scanInformationRelationStore.createIfAbsent(customerId, newScan.id, transaction) + } + .flatMap { + setKycStatus( + customerId = customerId, + regionCode = regionCode.toLowerCase(), + kycType = JUMIO, + kycStatus = KycStatus.PENDING, + transaction = transaction) + } + .flatMap { + newScan.right() + } + } + .ifFailedThenRollback(transaction) + } + + private fun getCustomerUsingScanId(scanId: String, transaction: Transaction): Either { + return scanInformationStore + .getRelatedFrom( + id = scanId, + relationType = scanInformationRelation, + transaction = transaction) + .flatMap { customers -> + customers.singleOrNull()?.right() + ?: NotFoundError(type = scanInformationEntity.name, id = scanId).left() + } + } + + override fun getCountryCodeForScan(scanId: String): Either = readTransaction { + scanInformationStore.get( + id = scanId, + transaction = transaction) + .flatMap { scanInformation -> + scanInformation.countryCode.right() + } + } + + // TODO merge into a single query which will use customerId and scanId + override fun getScanInformation(identity: org.ostelco.prime.model.Identity, scanId: String): Either = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + scanInformationStore.get(scanId, transaction).flatMap { scanInformation -> + getCustomerUsingScanId(scanInformation.scanId, transaction).flatMap { customer -> + // Check if the scan belongs to this customer + if (customer.id == customerId) { + scanInformation.right() + } else { + ValidationError(type = scanInformationEntity.name, id = scanId, message = "Not allowed").left() + } + } + } + } + } + + override fun getAllScanInformation(identity: org.ostelco.prime.model.Identity): Either> = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + customerStore.getRelated(customerId, scanInformationRelation, transaction) + } + } + + private val appNotifier by lazy { getResource() } + + override fun updateScanInformation(scanInformation: ScanInformation, vendorData: MultivaluedMap): Either = writeTransaction { + logger.info("updateScanInformation : ${scanInformation.scanId} status: ${scanInformation.status}") + getCustomerUsingScanId(scanInformation.scanId, transaction).flatMap { customer -> + scanInformationStore.update(scanInformation, transaction).flatMap { + logger.info("updating scan Information for : ${customer.contactEmail} id: ${scanInformation.scanId} status: ${scanInformation.status}") + val extendedStatus = scanInformationDatastore.getExtendedStatusInformation(vendorData) + if (scanInformation.status == ScanStatus.APPROVED) { + + logger.info("Inserting scan Information to cloud storage : id: ${scanInformation.scanId} countryCode: ${scanInformation.countryCode}") + scanInformationDatastore.upsertVendorScanInformation(customer.id, scanInformation.countryCode, vendorData) + .flatMap { + appNotifier.notify( + customerId = customer.id, + title = FCMStrings.NOTIFICATION_TITLE.s, + body = FCMStrings.JUMIO_IDENTITY_VERIFIED.s, + data = extendedStatus + ) + logger.info(NOTIFY_OPS_MARKER, "Jumio verification succeeded for ${customer.contactEmail} Info: ${extendedStatus}") + setKycStatus( + customerId = customer.id, + regionCode = scanInformation.countryCode.toLowerCase(), + kycType = JUMIO, + transaction = transaction) + } + } else { + // TODO: find out what more information can be passed to the client. + appNotifier.notify( + customerId = customer.id, + title = FCMStrings.NOTIFICATION_TITLE.s, + body = FCMStrings.JUMIO_IDENTITY_FAILED.s, + data = extendedStatus + ) + logger.warn(NOTIFY_OPS_MARKER, "Jumio verification failed for ${customer.contactEmail} Info: ${extendedStatus}") + setKycStatus( + customerId = customer.id, + regionCode = scanInformation.countryCode.toLowerCase(), + kycType = JUMIO, + kycStatus = REJECTED, + transaction = transaction) + } + } + }.ifFailedThenRollback(transaction) + } + + // + // eKYC - MyInfo + // + + private val myInfoKycService by lazy { getResource() } + + override fun getCustomerMyInfoData( + identity: org.ostelco.prime.model.Identity, + authorisationCode: String): Either { + + return getCustomerId(identity = identity) + .flatMap { customerId -> + // set MyInfo KYC Status to Pending + setKycStatus( + customerId = customerId, + regionCode = "sg", + kycType = MY_INFO, + kycStatus = KycStatus.PENDING) + .flatMap { + try { + myInfoKycService.getPersonData(authorisationCode).right() + } catch (e: Exception) { + logger.error("Failed to fetched MyInfo using authCode = $authorisationCode", e) + SystemError( + type = "MyInfo Auth Code", + id = authorisationCode, + message = "Failed to fetched MyInfo").left() + } + }.flatMap { personData -> + // set MyInfo KYC Status to Approved + setKycStatus( + customerId = customerId, + regionCode = "sg", + kycType = MY_INFO) + .map { personData } + } + } + } + + // + // eKYC - NRIC/FIN + // + + private val daveKycService by lazy { getResource() } + + override fun checkNricFinIdUsingDave( + identity: org.ostelco.prime.model.Identity, + nricFinId: String): Either = writeTransaction { + + logger.info("checkNricFinIdUsingDave for ${nricFinId}") + + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + setKycStatus( + customerId = customerId, + regionCode = "sg", + kycType = NRIC_FIN, + transaction = transaction) + } + .flatMap { + if (daveKycService.validate(nricFinId)) { + logger.info("checkNricFinIdUsingDave validated ${nricFinId}") + Unit.right() + } else { + logger.info("checkNricFinIdUsingDave failed to validate ${nricFinId}") + ValidationError(type = "NRIC/FIN ID", id = nricFinId, message = "Invalid NRIC/FIN ID").left() + } + } + .ifFailedThenRollback(transaction) + } + + // + // eKYC - Address and Phone number + // + override fun saveAddressAndPhoneNumber( + identity: org.ostelco.prime.model.Identity, + address: String, + phoneNumber: String): Either = writeTransaction { + + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + setKycStatus( + customerId = customerId, + regionCode = "sg", + kycType = ADDRESS_AND_PHONE_NUMBER, + transaction = transaction) + } + .ifFailedThenRollback(transaction) + } + + // + // eKYC - Status Flags + // + + internal fun setKycStatus( + customerId: String, + regionCode: String, + kycType: KycType, + kycStatus: KycStatus = KycStatus.APPROVED) = writeTransaction { + + setKycStatus( + customerId = customerId, + regionCode = regionCode, + kycType = kycType, + kycStatus = kycStatus, + transaction = transaction) + .ifFailedThenRollback(transaction) + } + + private fun setKycStatus( + customerId: String, + regionCode: String, + kycType: KycType, + kycStatus: KycStatus = KycStatus.APPROVED, + transaction: Transaction): Either { + + return IO { + Either.monad().binding { + + val approvedKycTypeSetList = getApprovedKycTypeSetList(regionCode) + + val existingCustomerRegion = customerRegionRelationStore.get( + fromId = customerId, + toId = regionCode, + transaction = transaction) + .getOrElse { CustomerRegion(status = PENDING, kycStatusMap = getKycStatusMapForRegion(regionCode)) } + + val newKycStatusMap = existingCustomerRegion.kycStatusMap.copy(key = kycType, value = kycStatus) + + val approved = approvedKycTypeSetList.any { kycTypeSet -> + newKycStatusMap.filter { it.value == KycStatus.APPROVED }.keys.containsAll(kycTypeSet) + } + + val approvedNow = existingCustomerRegion.status == PENDING && approved + + val newStatus = if (approved) { + APPROVED + } else { + existingCustomerRegion.status + } + + if (approvedNow) { + + assignCustomerToRegionSegment( + customerId = customerId, + regionCode = regionCode, + transaction = transaction).bind() + } + + customerRegionRelationStore + .createOrUpdate( + fromId = customerId, + relation = CustomerRegion(status = newStatus, kycStatusMap = newKycStatusMap), + toId = regionCode, + transaction = transaction) + .bind() + + }.fix() + }.unsafeRunSync() + } + + private fun getKycStatusMapForRegion(regionCode: String): Map { + return when (regionCode) { + "sg" -> setOf(JUMIO, MY_INFO, NRIC_FIN, ADDRESS_AND_PHONE_NUMBER) + else -> setOf(JUMIO) + }.map { it to KycStatus.PENDING }.toMap() + } + + private fun getApprovedKycTypeSetList(regionCode: String): List> { + return when (regionCode) { + "sg" -> listOf(setOf(MY_INFO), + setOf(JUMIO, NRIC_FIN, ADDRESS_AND_PHONE_NUMBER)) + else -> listOf(setOf(JUMIO)) + } } // ------------ @@ -519,12 +1386,12 @@ object Neo4jStoreSingleton : GraphStore { // ------------ // - // Balance (Subscriber - Subscription - Bundle) + // Balance (Customer - Subscription - Bundle) // override fun getMsisdnToBundleMap(): Map = readTransaction { read(""" - MATCH (subscription:${subscriptionEntity.name})-[:${subscriptionToBundleRelation.relation.name}]->(bundle:${bundleEntity.name})<-[:${subscriberToBundleRelation.relation.name}]-(:${subscriberEntity.name}) + MATCH (subscription:${subscriptionEntity.name})-[:${subscriptionToBundleRelation.name}]->(bundle:${bundleEntity.name})<-[:${customerToBundleRelation.name}]-(:${customerEntity.name}) RETURN subscription, bundle """.trimIndent(), transaction) { result -> @@ -537,7 +1404,7 @@ object Neo4jStoreSingleton : GraphStore { override fun getAllBundles(): Collection = readTransaction { read(""" - MATCH (:${subscriberEntity.name})-[:${subscriberToBundleRelation.relation.name}]->(bundle:${bundleEntity.name})<-[:${subscriptionToBundleRelation.relation.name}]-(:${subscriptionEntity.name}) + MATCH (:${customerEntity.name})-[:${customerToBundleRelation.name}]->(bundle:${bundleEntity.name})<-[:${subscriptionToBundleRelation.name}]-(:${subscriptionEntity.name}) RETURN bundle """.trimIndent(), transaction) { result -> @@ -547,70 +1414,350 @@ object Neo4jStoreSingleton : GraphStore { } } - override fun getSubscriberToBundleIdMap(): Map = readTransaction { + override fun getCustomerToBundleIdMap(): Map = readTransaction { read(""" - MATCH (subscriber:${subscriberEntity.name})-[:${subscriberToBundleRelation.relation.name}]->(bundle:${bundleEntity.name}) - RETURN subscriber, bundle + MATCH (customer:${customerEntity.name})-[:${customerToBundleRelation.name}]->(bundle:${bundleEntity.name}) + RETURN customer, bundle """.trimIndent(), transaction) { result -> result.list { - Pair(ObjectHandler.getObject(it["subscriber"].asMap(), Subscriber::class.java), + Pair(ObjectHandler.getObject(it["customer"].asMap(), Customer::class.java), ObjectHandler.getObject(it["bundle"].asMap(), Bundle::class.java)) }.toMap() } } - override fun getSubscriberToMsisdnMap(): Map = readTransaction { + override fun getCustomerToMsisdnMap(): Map = readTransaction { read(""" - MATCH (subscriber:${subscriberEntity.name})-[:${subscriptionRelation.relation.name}]->(subscription:${subscriptionEntity.name}) - RETURN subscriber, subscription + MATCH (customer:${customerEntity.name})-[:${subscriptionRelation.name}]->(subscription:${subscriptionEntity.name}) + RETURN customer, subscription """.trimIndent(), transaction) { result -> result.list { - Pair(ObjectHandler.getObject(it["subscriber"].asMap(), Subscriber::class.java), + Pair(ObjectHandler.getObject(it["customer"].asMap(), Customer::class.java), ObjectHandler.getObject(it["subscription"].asMap(), Subscription::class.java)) }.toMap() } } + override fun getCustomerForMsisdn(msisdn: String): Either = readTransaction { + read(""" + MATCH (customer:${customerEntity.name})-[:${subscriptionRelation.name}]->(subscription:${subscriptionEntity.name} {msisdn: '$msisdn'}) + RETURN customer + """.trimIndent(), + transaction) { + if (it.hasNext()) + Either.right(customerEntity.createEntity(it.single().get("customer").asMap())) + else + Either.left(NotFoundError(type = customerEntity.name, id = msisdn)) + } + } + // // For metrics // - override fun getSubscriberCount(): Long = readTransaction { + + override fun getCustomerCount(): Long = readTransaction { read(""" - MATCH (subscriber:${subscriberEntity.name}) - RETURN count(subscriber) AS count + MATCH (customer:${customerEntity.name}) + RETURN count(customer) AS count """.trimIndent(), transaction) { result -> result.single().get("count").asLong() } } - override fun getReferredSubscriberCount(): Long = readTransaction { + override fun getReferredCustomerCount(): Long = readTransaction { read(""" - MATCH (:${subscriberEntity.name})-[:${referredRelation.relation.name}]->(subscriber:${subscriberEntity.name}) - RETURN count(subscriber) AS count + MATCH (:${customerEntity.name})-[:${referredRelation.name}]->(customer:${customerEntity.name}) + RETURN count(customer) AS count """.trimIndent(), transaction) { result -> result.single().get("count").asLong() } } - override fun getPaidSubscriberCount(): Long = readTransaction { - getPaidSubscriberCount(transaction) - } - - private fun getPaidSubscriberCount(transaction: Transaction): Long { - return read(""" - MATCH (subscriber:${subscriberEntity.name})-[:${purchaseRecordRelation.relation.name}]->(product:${productEntity.name}) + override fun getPaidCustomerCount(): Long = readTransaction { + read(""" + MATCH (customer:${customerEntity.name})-[:${purchaseRecordRelation.name}]->(product:${productEntity.name}) WHERE product.`price/amount` > 0 - RETURN count(subscriber) AS count - """.trimIndent(), - transaction) { result -> + RETURN count(customer) AS count + """.trimIndent(), transaction) { result -> result.single().get("count").asLong() } } + // + // For plans and subscriptions + // + + override fun getPlan(planId: String): Either = readTransaction { + plansStore.get(planId, transaction) + } + + override fun getPlans(identity: org.ostelco.prime.model.Identity): Either> = readTransaction { + getCustomerId(identity = identity, transaction = transaction) + .flatMap { customerId -> + customerStore.getRelated(id = customerId, relationType = subscribesToPlanRelation, transaction = transaction) + } + } + + override fun createPlan(plan: Plan): Either = writeTransaction { + IO { + Either.monad().binding { + + productStore.get(plan.id, transaction) + .fold( + { Unit.right() }, + { + Either.left(AlreadyExistsError(type = productEntity.name, id = "Failed to find product associated with plan ${plan.id}")) + } + ).bind() + plansStore.get(plan.id, transaction) + .fold( + { Unit.right() }, + { + Either.left(AlreadyExistsError(type = planEntity.name, id = "Failed to find plan ${plan.id}")) + } + ).bind() + + val productInfo = paymentProcessor.createProduct(plan.id) + .mapLeft { + NotCreatedError(type = planEntity.name, id = "Failed to create plan ${plan.id}", + error = it) + }.linkReversalActionToTransaction(transaction) { + paymentProcessor.removeProduct(it.id) + }.bind() + val planInfo = paymentProcessor.createPlan(productInfo.id, plan.price.amount, plan.price.currency, + PaymentProcessor.Interval.valueOf(plan.interval.toUpperCase()), plan.intervalCount) + .mapLeft { + NotCreatedError(type = planEntity.name, id = "Failed to create plan ${plan.id}", + error = it) + }.linkReversalActionToTransaction(transaction) { + paymentProcessor.removePlan(it.id) + }.bind() + + /* The associated product to the plan. Note that: + sku - name of the plan + property value 'productType' is set to "plan" + TODO: Complete support for 'product-class' and merge 'plan' and 'product' objects + into one object differentiated by 'product-class'. */ + val product = Product(sku = plan.id, price = plan.price, + properties = plan.properties + mapOf( + "productType" to "plan", + "interval" to plan.interval, + "intervalCount" to plan.intervalCount.toString()), + presentation = plan.presentation) + + /* Propagates errors from lower layer if any. */ + productStore.create(product, transaction) + .bind() + plansStore.create(plan.copy(properties = plan.properties.plus(mapOf( + "planId" to planInfo.id, + "productId" to productInfo.id))), transaction) + .bind() + planProductRelationStore.create(plan.id, product.id, transaction) + .bind() + plansStore.get(plan.id, transaction) + .bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + + override fun deletePlan(planId: String): Either = writeTransaction { + IO { + Either.monad().binding { + val plan = plansStore.get(planId, transaction) + .bind() + /* The name of the product is the same as the name of the corresponding plan. */ + productStore.get(planId, transaction) + .bind() + plansStore.getRelated(id = plan.id, relationType = planProductRelation, transaction = transaction) + .bind() + + /* Not removing the product due to purchase references. */ + + /* Removing the plan will remove the plan itself and all relations going to it. */ + plansStore.delete(plan.id, transaction) + .bind() + + /* Lookup in payment backend will fail if no value found for 'planId'. */ + paymentProcessor.removePlan(plan.properties.getOrDefault("planId", "missing")) + .mapLeft { + NotDeletedError(type = planEntity.name, id = "Failed to delete ${plan.id}", + error = it) + }.linkReversalActionToTransaction(transaction) { + /* (Nothing to do.) */ + }.flatMap { + Unit.right() + }.bind() + /* Lookup in payment backend will fail if no value found for 'productId'. */ + paymentProcessor.removeProduct(plan.properties.getOrDefault("productId", "missing")) + .mapLeft { + NotDeletedError(type = planEntity.name, id = "Failed to delete ${plan.id}", + error = it) + }.linkReversalActionToTransaction(transaction) { + /* (Nothing to do.) */ + }.bind() + plan + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + + override fun subscribeToPlan(identity: org.ostelco.prime.model.Identity, planId: String, trialEnd: Long): Either = writeTransaction { + IO { + Either.monad().binding { + val customerId = getCustomerId(identity = identity, transaction = transaction) + .bind() + val customer = customerStore.get(customerId, transaction) + .bind() + val plan = plansStore.get(planId, transaction) + .bind() + plansStore.getRelated(id = plan.id, relationType = planProductRelation, transaction = transaction) + .bind() + val profileInfo = paymentProcessor.getPaymentProfile(customer.id) + .mapLeft { + NotFoundError(type = planEntity.name, id = "Failed to subscribe ${customer.id} to ${plan.id}", + error = it) + }.bind() + + /* Lookup in payment backend will fail if no value found for 'planId'. */ + val subscriptionInfo = paymentProcessor.createSubscription(plan.properties.getOrDefault("planId", "missing"), + profileInfo.id, trialEnd) + .mapLeft { + NotCreatedError(type = planEntity.name, id = "Failed to subscribe $customerId to ${plan.id}", + error = it) + }.linkReversalActionToTransaction(transaction) { + paymentProcessor.cancelSubscription(it.id) + }.bind() + + /* Store information from payment backend for later use. */ + subscribesToPlanRelationStore.create( + fromId = customerId, + relation = PlanSubscription( + subscriptionId = subscriptionInfo.id, + created = subscriptionInfo.created, + trialEnd = subscriptionInfo.trialEnd), + toId = planId, + transaction = transaction) + .flatMap { + Either.right(plan) + }.bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + + override fun unsubscribeFromPlan(identity: org.ostelco.prime.model.Identity, planId: String, atIntervalEnd: Boolean): Either = writeTransaction { + IO { + Either.monad().binding { + val plan = plansStore.get(planId, transaction) + .bind() + val customerId = getCustomerId(identity = identity, transaction = transaction) + .bind() + val planSubscription = subscribesToPlanRelationStore.get(customerId, planId, transaction) + .bind() + paymentProcessor.cancelSubscription(planSubscription.subscriptionId, atIntervalEnd) + .mapLeft { + NotDeletedError(type = planEntity.name, id = "$customerId -> ${plan.id}", + error = it) + }.flatMap { + Unit.right() + }.bind() + + subscribesToPlanRelationStore.delete(customerId, planId, transaction) + .flatMap { + Either.right(plan) + }.bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + + override fun subscriptionPurchaseReport(invoiceId: String, customerId: String, sku: String, amount: Long, currency: String): Either = writeTransaction { + IO { + Either.monad().binding { + val product = productStore.get(sku, transaction) + .bind() + val plan = productStore.getRelatedFrom(id = sku, relationType = planProductRelation, transaction = transaction) + .flatMap { + it[0].right() + }.bind() + val purchaseRecord = PurchaseRecord( + id = invoiceId, + product = product, + timestamp = Instant.now().toEpochMilli()) + + createPurchaseRecordRelation(customerId, purchaseRecord, transaction).bind() + plan + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + + // + // For refunds + // + + private fun checkPurchaseRecordForRefund(purchaseRecord: PurchaseRecord): Either { + if (purchaseRecord.refund != null) { + logger.error("Trying to refund again, ${purchaseRecord.id}, refund ${purchaseRecord.refund?.id}") + return Either.left(ForbiddenError("Trying to refund again")) + } else if (purchaseRecord.product.price.amount == 0) { + logger.error("Trying to refund a free product, ${purchaseRecord.id}") + return Either.left(ForbiddenError("Trying to refund a free purchase")) + } + return Unit.right() + } + + private fun updatePurchaseRecord( + purchase: PurchaseRecord, + primeTransaction: PrimeTransaction): Either = changablePurchaseRelationStore.update(purchase, primeTransaction) + + override fun refundPurchase( + identity: org.ostelco.prime.model.Identity, + purchaseRecordId: String, + reason: String): Either = writeTransaction { + IO { + Either.monad().binding { + val (_, customerAnalyticsId) = getCustomerAndAnalyticsId(identity = identity, transaction = transaction) + .mapLeft { + logger.error("Failed to find customer with identity - $identity") + NotFoundPaymentError("Failed to find customer with identity - $identity", + error = it) + }.bind() + val purchaseRecord = changablePurchaseRelationStore.get(purchaseRecordId, transaction) + // If we can't find the record, return not-found + .mapLeft { + org.ostelco.prime.paymentprocessor.core.NotFoundError("Purchase Record unavailable", + error = it) + }.bind() + checkPurchaseRecordForRefund(purchaseRecord).bind() + val refundId = paymentProcessor.refundCharge( + purchaseRecord.id, + purchaseRecord.product.price.amount, + purchaseRecord.product.price.currency).bind() + val refund = RefundRecord(refundId, reason, Instant.now().toEpochMilli()) + val changedPurchaseRecord = PurchaseRecord( + id = purchaseRecord.id, + product = purchaseRecord.product, + timestamp = purchaseRecord.timestamp, + refund = refund) + updatePurchaseRecord(changedPurchaseRecord, transaction) + .mapLeft { + logger.error("failed to update purchase record, for refund $refund.id, chargeId $purchaseRecordId, payment has been refunded in Stripe") + BadGatewayError("Failed to update purchase record for refund ${refund.id}", + error = it) + }.bind() + analyticsReporter.reportPurchaseInfo(purchaseRecord, customerAnalyticsId, "refunded") + ProductInfo(purchaseRecord.product.sku) + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + // // Stores // @@ -621,18 +1768,27 @@ object Neo4jStoreSingleton : GraphStore { private val segmentEntity = EntityType(Segment::class.java) private val segmentStore = EntityStore(segmentEntity) - private val offerToSegmentRelation = RelationType(OFFERED_TO_SEGMENT, offerEntity, segmentEntity, Void::class.java) + private val offerToSegmentRelation = RelationType(OFFERED_TO_SEGMENT, offerEntity, segmentEntity, None::class.java) private val offerToSegmentStore = RelationStore(offerToSegmentRelation) - private val offerToProductRelation = RelationType(OFFER_HAS_PRODUCT, offerEntity, productEntity, Void::class.java) + private val offerToProductRelation = RelationType(OFFER_HAS_PRODUCT, offerEntity, productEntity, None::class.java) private val offerToProductStore = RelationStore(offerToProductRelation) - private val subscriberToSegmentRelation = RelationType(BELONG_TO_SEGMENT, subscriberEntity, segmentEntity, Void::class.java) - private val subscriberToSegmentStore = RelationStore(subscriberToSegmentRelation) + private val customerToSegmentRelation = RelationType(BELONG_TO_SEGMENT, customerEntity, segmentEntity, None::class.java) + private val customerToSegmentStore = RelationStore(customerToSegmentRelation) private val productClassEntity = EntityType(ProductClass::class.java) private val productClassStore = EntityStore(productClassEntity) + // + // Region + // + + override fun createRegion(region: Region): Either = writeTransaction { + regionStore.create(region, transaction) + .ifFailedThenRollback(transaction) + } + // // Product Class // @@ -662,7 +1818,7 @@ object Neo4jStoreSingleton : GraphStore { private fun createSegment(segment: Segment, transaction: Transaction): Either { return segmentStore.create(segment, transaction) - .flatMap { subscriberToSegmentStore.create(segment.subscribers, segment.id, transaction) } + .flatMap { customerToSegmentStore.create(segment.subscribers, segment.id, transaction) } } override fun updateSegment(segment: Segment): Either = writeTransaction { @@ -671,8 +1827,8 @@ object Neo4jStoreSingleton : GraphStore { } private fun updateSegment(segment: Segment, transaction: Transaction): Either { - return subscriberToSegmentStore.removeAll(toId = segment.id, transaction = transaction) - .flatMap { subscriberToSegmentStore.create(segment.subscribers, segment.id, transaction) } + return customerToSegmentStore.removeAll(toId = segment.id, transaction = transaction) + .flatMap { customerToSegmentStore.create(segment.subscribers, segment.id, transaction) } } // @@ -721,7 +1877,7 @@ object Neo4jStoreSingleton : GraphStore { } // end of validation - var result = Either.right(Unit) as Either + var result = Unit.right() as Either result = products.fold( initial = result, @@ -751,7 +1907,7 @@ object Neo4jStoreSingleton : GraphStore { override fun atomicCreateSegments(createSegments: Collection): Either = writeTransaction { createSegments.fold( - initial = Either.right(Unit) as Either, + initial = Unit.right() as Either, operation = { acc, segment -> acc.flatMap { createSegment(segment, transaction) } }) @@ -764,18 +1920,24 @@ object Neo4jStoreSingleton : GraphStore { override fun atomicUpdateSegments(updateSegments: Collection): Either = writeTransaction { updateSegments.fold( - initial = Either.right(Unit) as Either, + initial = Unit.right() as Either, operation = { acc, segment -> acc.flatMap { updateSegment(segment, transaction) } }) .ifFailedThenRollback(transaction) } - override fun atomicAddToSegments(addToSegments: Collection): Either { TODO() } + override fun atomicAddToSegments(addToSegments: Collection): Either { + TODO() + } - override fun atomicRemoveFromSegments(removeFromSegments: Collection): Either { TODO() } + override fun atomicRemoveFromSegments(removeFromSegments: Collection): Either { + TODO() + } - override fun atomicChangeSegments(changeSegments: Collection): Either { TODO() } + override fun atomicChangeSegments(changeSegments: Collection): Either { + TODO() + } // override fun getOffers(): Collection = offerStore.getAll().values.map { Offer().apply { id = it.id } } @@ -786,4 +1948,20 @@ object Neo4jStoreSingleton : GraphStore { // override fun getSegment(id: String): Segment? = segmentStore.get(id)?.let { Segment().apply { this.id = it.id } } // override fun getProductClass(id: String): ProductClass? = productClassStore.get(id) + + // + // Indexes + // + + fun createIndex() = writeTransaction { + write(query = "CREATE INDEX ON :${identityEntity.name}(id)", transaction = transaction) {} + write(query = "CREATE INDEX ON :${subscriptionEntity.name}(id)", transaction = transaction) {} + write(query = "CREATE INDEX ON :${bundleEntity.name}(id)", transaction = transaction) {} + } +} + +fun Map.copy(key: K, value: V): Map { + val mutableMap = this.toMutableMap() + mutableMap[key] = value + return mutableMap.toMap() } \ No newline at end of file diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/PrimeTransaction.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/PrimeTransaction.kt index dd6dfecc5..62d095e8e 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/PrimeTransaction.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/PrimeTransaction.kt @@ -2,14 +2,19 @@ package org.ostelco.prime.storage.graph import arrow.core.Either import org.neo4j.driver.v1.Transaction +import org.ostelco.prime.getLogger import org.ostelco.prime.storage.graph.ActionType.FINAL import org.ostelco.prime.storage.graph.ActionType.REVERSAL class PrimeTransaction(private val transaction: Transaction) : Transaction by transaction { + private val logger by getLogger() + private val reversalActions = mutableListOf<() -> Unit>() private val finalActions = mutableListOf<() -> Unit>() + private var success = true + private fun toActionList(actionType: ActionType) = when (actionType) { REVERSAL -> reversalActions FINAL -> finalActions @@ -28,12 +33,14 @@ class PrimeTransaction(private val transaction: Transaction) : Transaction by tr } override fun failure() { + success = false transaction.failure() - doActions(REVERSAL) } override fun close() { - transaction.close() + if (!success) { + doActions(REVERSAL) + } finalActions.reverse() doActions(FINAL) } diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt index 32ee8c881..82a5014df 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt @@ -2,13 +2,18 @@ package org.ostelco.prime.storage.graph import arrow.core.Either import arrow.core.flatMap +import arrow.core.left +import arrow.core.right import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.neo4j.driver.v1.AccessMode.READ import org.neo4j.driver.v1.AccessMode.WRITE import org.neo4j.driver.v1.StatementResult +import org.neo4j.driver.v1.StatementResultCursor import org.neo4j.driver.v1.Transaction import org.ostelco.prime.getLogger +import org.ostelco.prime.jsonmapper.objectMapper import org.ostelco.prime.model.HasId import org.ostelco.prime.storage.AlreadyExistsError import org.ostelco.prime.storage.NotCreatedError @@ -19,6 +24,7 @@ import org.ostelco.prime.storage.StoreError import org.ostelco.prime.storage.graph.Graph.read import org.ostelco.prime.storage.graph.Graph.write import org.ostelco.prime.storage.graph.ObjectHandler.getProperties +import java.util.concurrent.CompletionStage // // Schema classes @@ -34,12 +40,13 @@ data class EntityType( } data class RelationType( - val relation: Relation, + private val relation: Relation, val from: EntityType, val to: EntityType, - private val dataClass: Class) { + private val dataClass: Class, + val name: String = relation.name) { - fun createRelation(map: Map): RELATION? { + fun createRelation(map: Map): RELATION { return ObjectHandler.getObject(map, dataClass) } } @@ -51,9 +58,10 @@ class EntityStore(private val entityType: EntityType) { } fun get(id: String, transaction: Transaction): Either { - return read("""MATCH (node:${entityType.name} {id: '$id'}) RETURN node;""", transaction) { - if (it.hasNext()) - Either.right(entityType.createEntity(it.single().get("node").asMap())) + return read("""MATCH (node:${entityType.name} {id: '$id'}) RETURN node;""", + transaction) { statementResult -> + if (statementResult.hasNext()) + Either.right(entityType.createEntity(statementResult.single().get("node").asMap())) else Either.left(NotFoundError(type = entityType.name, id = id)) } @@ -61,34 +69,33 @@ class EntityStore(private val entityType: EntityType) { fun create(entity: E, transaction: Transaction): Either { - if (get(entity.id, transaction).isRight()) { - return Either.left(AlreadyExistsError(type = entityType.name, id = entity.id)) - } + return doNotExist(id = entity.id, transaction = transaction).flatMap { - val properties = getProperties(entity) - val strProps: String = properties.entries.joinToString(separator = ",") { """ `${it.key}`: "${it.value}"""" } - .let { if (it.isNotBlank()) ",$it" else it } - return write("""CREATE (node:${entityType.name} { id:"${entity.id}"$strProps });""", - transaction) { - if (it.summary().counters().nodesCreated() == 1) - Either.right(Unit) - else - Either.left(NotCreatedError(type = entityType.name, id = entity.id)) + val properties = getProperties(entity) + val strProps: String = properties.entries.joinToString(separator = ",") { """ `${it.key}`: "${it.value}"""" } + .let { if (it.isNotBlank()) ",$it" else it } + write("""CREATE (node:${entityType.name} { id:"${entity.id}"$strProps });""", + transaction) { + if (it.summary().counters().nodesCreated() == 1) + Unit.right() + else + Either.left(NotCreatedError(type = entityType.name, id = entity.id)) + } } } fun create(id: String, transaction: Transaction): Either { - if (get(id, transaction).isRight()) { - return Either.left(AlreadyExistsError(type = entityType.name, id = id)) - } + return doNotExist(id = id, transaction = transaction).flatMap { - return write("""CREATE (node:${entityType.name} { id:"$id"});""", - transaction) { - if (it.summary().counters().nodesCreated() == 1) - Either.right(Unit) - else - Either.left(NotCreatedError(type = entityType.name, id = id)) + write("""CREATE (node:${entityType.name} { id:"$id"});""", + transaction) { statementResult -> + + if (statementResult.summary().counters().nodesCreated() == 1) + Unit.right() + else + Either.left(NotCreatedError(type = entityType.name, id = id)) + } } } @@ -97,15 +104,15 @@ class EntityStore(private val entityType: EntityType) { relationType: RelationType, transaction: Transaction): Either> { - return exists(id, transaction).flatMap { _ -> + return exists(id, transaction).flatMap { read(""" - MATCH (:${relationType.from.name} {id: '$id'})-[:${relationType.relation.name}]->(node:${relationType.to.name}) + MATCH (:${relationType.from.name} {id: '$id'})-[:${relationType.name}]->(node:${relationType.to.name}) RETURN node; """.trimIndent(), transaction) { statementResult -> Either.right( - statementResult.list { relationType.to.createEntity(it["node"].asMap()) }) + statementResult.list { record -> relationType.to.createEntity(record["node"].asMap()) }) } } } @@ -115,15 +122,15 @@ class EntityStore(private val entityType: EntityType) { relationType: RelationType, transaction: Transaction): Either> { - return exists(id, transaction).flatMap { _ -> + return exists(id, transaction).flatMap { read(""" - MATCH (node:${relationType.from.name})-[:${relationType.relation.name}]->(:${relationType.to.name} {id: '$id'}) + MATCH (node:${relationType.from.name})-[:${relationType.name}]->(:${relationType.to.name} {id: '$id'}) RETURN node; """.trimIndent(), transaction) { statementResult -> Either.right( - statementResult.list { relationType.from.createEntity(it["node"].asMap()) }) + statementResult.list { record -> relationType.from.createEntity(record["node"].asMap()) }) } } } @@ -133,15 +140,15 @@ class EntityStore(private val entityType: EntityType) { relationType: RelationType, transaction: Transaction): Either> { - return exists(id, transaction).flatMap { _ -> + return exists(id, transaction).flatMap { + read(""" - MATCH (from:${entityType.name} { id: '$id' })-[r:${relationType.relation.name}]-() + MATCH (from:${entityType.name} { id: '$id' })-[r:${relationType.name}]-() return r; """.trimIndent(), transaction) { statementResult -> - Either.right( - statementResult.list { relationType.createRelation(it["r"].asMap()) } - .filterNotNull()) + statementResult.list { record -> relationType.createRelation(record["r"].asMap()) } + .right() } } } @@ -150,11 +157,12 @@ class EntityStore(private val entityType: EntityType) { return exists(entity.id, transaction).flatMap { val properties = getProperties(entity) - val setClause: String = properties.entries.fold("") { acc, entry -> """$acc SET node.${entry.key} = "${entry.value}" """ } + // TODO vihang: replace setClause with map based settings written by Kjell + val setClause: String = properties.entries.fold("") { acc, entry -> """$acc SET node.`${entry.key}` = '${entry.value}' """ } write("""MATCH (node:${entityType.name} { id: '${entity.id}' }) $setClause ;""", - transaction) { + transaction) { statementResult -> Either.cond( - test = it.summary().counters().containsUpdates(), // TODO vihang: this is not perfect way to check if updates are applied + test = statementResult.summary().counters().containsUpdates(), // TODO vihang: this is not perfect way to check if updates are applied ifTrue = {}, ifFalse = { NotUpdatedError(type = entityType.name, id = entity.id) }) } @@ -164,9 +172,9 @@ class EntityStore(private val entityType: EntityType) { fun delete(id: String, transaction: Transaction): Either = exists(id, transaction).flatMap { write("""MATCH (node:${entityType.name} {id: '$id'} ) DETACH DELETE node;""", - transaction) { + transaction) { statementResult -> Either.cond( - test = it.summary().counters().nodesDeleted() == 1, + test = statementResult.summary().counters().nodesDeleted() == 1, ifTrue = {}, ifFalse = { NotDeletedError(type = entityType.name, id = id) }) } @@ -181,7 +189,7 @@ class EntityStore(private val entityType: EntityType) { ifFalse = { NotFoundError(type = entityType.name, id = id) }) } - fun doNotExist(id: String, transaction: Transaction): Either = + private fun doNotExist(id: String, transaction: Transaction): Either = read("""MATCH (node:${entityType.name} {id: '$id'} ) RETURN count(node);""", transaction) { statementResult -> Either.cond( @@ -192,59 +200,68 @@ class EntityStore(private val entityType: EntityType) { } // TODO vihang: check if relation already exists, with allow duplicate boolean flag param -class RelationStore(private val relationType: RelationType) { +class RelationStore(private val relationType: RelationType) { - fun create(from: FROM, relation: Any, to: TO, transaction: Transaction): Either { + fun create(from: FROM, relation: RELATION, to: TO, transaction: Transaction): Either { - val properties = getProperties(relation) + val properties = getProperties(relation as Any) val strProps: String = properties.entries.joinToString(",") { """`${it.key}`: "${it.value}"""" } return write(""" - MATCH (from:${relationType.from.name} { id: '${from.id}' }),(to:${relationType.to.name} { id: '${to.id}' }) - CREATE (from)-[:${relationType.relation.name} { $strProps } ]->(to); - """.trimIndent(), - transaction) { + MATCH (from:${relationType.from.name} { id: '${from.id}' }),(to:${relationType.to.name} { id: '${to.id}' }) + CREATE (from)-[:${relationType.name} { $strProps } ]->(to); + """.trimIndent(), + transaction) { statementResult -> + // TODO vihang: validate if 'from' and 'to' node exists Either.cond( - test = it.summary().counters().relationshipsCreated() == 1, + test = statementResult.summary().counters().relationshipsCreated() == 1, ifTrue = {}, - ifFalse = { NotCreatedError(type = relationType.relation.name, id = "${from.id} -> ${to.id}") }) + ifFalse = { NotCreatedError(type = relationType.name, id = "${from.id} -> ${to.id}") }) } } fun create(from: FROM, to: TO, transaction: Transaction): Either = write(""" MATCH (from:${relationType.from.name} { id: '${from.id}' }),(to:${relationType.to.name} { id: '${to.id}' }) - CREATE (from)-[:${relationType.relation.name}]->(to); + CREATE (from)-[:${relationType.name}]->(to); """.trimIndent(), - transaction) { + transaction) { statementResult -> + // TODO vihang: validate if 'from' and 'to' node exists Either.cond( - test = it.summary().counters().relationshipsCreated() == 1, + test = statementResult.summary().counters().relationshipsCreated() == 1, ifTrue = {}, - ifFalse = { NotCreatedError(type = relationType.relation.name, id = "${from.id} -> ${to.id}") }) + ifFalse = { NotCreatedError(type = relationType.name, id = "${from.id} -> ${to.id}") }) } fun create(fromId: String, toId: String, transaction: Transaction): Either = write(""" MATCH (from:${relationType.from.name} { id: '$fromId' }),(to:${relationType.to.name} { id: '$toId' }) - CREATE (from)-[:${relationType.relation.name}]->(to); + CREATE (from)-[:${relationType.name}]->(to); """.trimIndent(), - transaction) { + transaction) { statementResult -> + // TODO vihang: validate if 'from' and 'to' node exists Either.cond( - test = it.summary().counters().relationshipsCreated() == 1, + test = statementResult.summary().counters().relationshipsCreated() == 1, ifTrue = {}, - ifFalse = { NotCreatedError(type = relationType.relation.name, id = "$fromId -> $toId") }) + ifFalse = { NotCreatedError(type = relationType.name, id = "$fromId -> $toId") }) } - fun create(fromId: String, relation: Any, toId: String, transaction: Transaction): Either = write(""" + fun create(fromId: String, relation: RELATION, toId: String, transaction: Transaction): Either { + + val properties = getProperties(relation as Any) + val strProps: String = properties.entries.joinToString(",") { """`${it.key}`: "${it.value}"""" } + return write(""" MATCH (from:${relationType.from.name} { id: '$fromId' }),(to:${relationType.to.name} { id: '$toId' }) - CREATE (from)-[:${relationType.relation.name}]->(to); + CREATE (from)-[:${relationType.name} { $strProps } ]->(to); """.trimIndent(), - transaction) { - // TODO vihang: validate if 'from' and 'to' node exists - Either.cond( - test = it.summary().counters().relationshipsCreated() == 1, - ifTrue = {}, - ifFalse = { NotCreatedError(type = relationType.relation.name, id = "$fromId -> $toId") }) + transaction) { statementResult -> + + // TODO vihang: validate if 'from' and 'to' node exists + Either.cond( + test = statementResult.summary().counters().relationshipsCreated() == 1, + ifTrue = {}, + ifFalse = { NotCreatedError(type = relationType.name, id = "$fromId -> $toId") }) + } } fun create(fromId: String, toIds: Collection, transaction: Transaction): Either = write(""" @@ -252,17 +269,17 @@ class RelationStore(private val relationType: Relation WHERE to.id in [${toIds.joinToString(",") { "'$it'" }}] WITH to MATCH (from:${relationType.from.name} { id: '$fromId' }) - CREATE (from)-[:${relationType.relation.name}]->(to); + CREATE (from)-[:${relationType.name}]->(to); """.trimIndent(), - transaction) { + transaction) { statementResult -> // TODO vihang: validate if 'from' and 'to' node exists - val actualCount = it.summary().counters().relationshipsCreated() + val actualCount = statementResult.summary().counters().relationshipsCreated() Either.cond( test = actualCount == toIds.size, ifTrue = {}, ifFalse = { NotCreatedError( - type = relationType.relation.name, + type = relationType.name, expectedCount = toIds.size, actualCount = actualCount) }) @@ -273,29 +290,238 @@ class RelationStore(private val relationType: Relation WHERE from.id in [${fromIds.joinToString(",") { "'$it'" }}] WITH from MATCH (to:${relationType.to.name} { id: '$toId' }) - CREATE (from)-[:${relationType.relation.name}]->(to); + CREATE (from)-[:${relationType.name}]->(to); """.trimIndent(), - transaction) { + transaction) { statementResult -> + // TODO vihang: validate if 'from' and 'to' node exists - val actualCount = it.summary().counters().relationshipsCreated() + val actualCount = statementResult.summary().counters().relationshipsCreated() Either.cond( test = actualCount == fromIds.size, ifTrue = {}, ifFalse = { NotCreatedError( - type = relationType.relation.name, + type = relationType.name, expectedCount = fromIds.size, actualCount = actualCount) }) } fun removeAll(toId: String, transaction: Transaction): Either = write(""" - MATCH (from:${relationType.from.name})-[r:${relationType.relation.name}]->(to:${relationType.to.name} { id: '$toId' }) + MATCH (from:${relationType.from.name})-[r:${relationType.name}]->(to:${relationType.to.name} { id: '$toId' }) DELETE r; - """.trimIndent(), + """.trimIndent(), transaction) { // TODO vihang: validate if 'to' node exists - Either.right(Unit) + Unit.right() + } +} + +class ChangeableRelationStore(private val relationType: RelationType) { + + fun get(id: String, transaction: Transaction): Either { + return read("""MATCH (from)-[r:${relationType.name}{id:'$id'}]->(to) RETURN r;""", + transaction) { statementResult -> + if (statementResult.hasNext()) { + relationType.createRelation(statementResult.single().get("r").asMap()).right() + } else { + Either.left(NotFoundError(type = relationType.name, id = id)) + } + } + } + + fun update(relation: RELATION, transaction: Transaction): Either { + val properties = getProperties(relation) + // TODO vihang: replace setClause with map based settings written by Kjell + val setClause: String = properties.entries.fold("") { acc, entry -> """$acc SET r.`${entry.key}` = "${entry.value}" """ } + return write("""MATCH (from)-[r:${relationType.name}{id:'${relation.id}'}]->(to) $setClause ;""", + transaction) { statementResult -> + Either.cond( + test = statementResult.summary().counters().containsUpdates(), // TODO vihang: this is not perfect way to check if updates are applied + ifTrue = {}, + ifFalse = { NotUpdatedError(type = relationType.name, id = relation.id) }) + } + } +} + +// Removes double apostrophes from key values in a JSON string. +// Usage: output = re.replace(input, "$1$2$3") +val re = Regex("""([,{])\s*"([^"]+)"\s*(:)""") + +class UniqueRelationStore(private val relationType: RelationType) { + + // If relation does not exists, then it creates new relation. + fun createIfAbsent(fromId: String, toId: String, transaction: Transaction): Either { + + return (relationType.from.entityStore?.exists(fromId, transaction) + ?: NotFoundError(type = relationType.from.name, id = fromId).left()) + .flatMap { + relationType.to.entityStore?.exists(toId, transaction) + ?: NotFoundError(type = relationType.to.name, id = toId).left() + }.flatMap { + + doNotExist(fromId, toId, transaction).fold( + { Unit.right() }, + { + write(""" + MATCH (fromId:${relationType.from.name} {id: '$fromId'}),(toId:${relationType.to.name} {id: '$toId'}) + MERGE (fromId)-[:${relationType.name}]->(toId) + """.trimMargin(), + transaction) { statementResult -> + + Either.cond(statementResult.summary().counters().relationshipsCreated() == 1, + ifTrue = { Unit }, + ifFalse = { NotCreatedError(relationType.name, "$fromId -> $toId") }) + } + }) + } + } + + // If relation does not exists, then it creates new relation. + fun createIfAbsent(fromId: String, relation: RELATION, toId: String, transaction: Transaction): Either { + + return (relationType.from.entityStore?.exists(fromId, transaction) + ?: NotFoundError(type = relationType.from.name, id = fromId).left()) + .flatMap { + relationType.to.entityStore?.exists(toId, transaction) + ?: NotFoundError(type = relationType.to.name, id = toId).left() + }.flatMap { + + doNotExist(fromId, toId, transaction).fold( + { Unit.right() }, + { + val properties = getProperties(relation as Any) + val strProps: String = properties.entries.joinToString(",") { """`${it.key}`: "${it.value}"""" } + + write(""" + MATCH (fromId:${relationType.from.name} {id: '$fromId'}),(toId:${relationType.to.name} {id: '$toId'}) + MERGE (fromId)-[:${relationType.name} { $strProps } ]->(toId) + """.trimMargin(), + transaction) { statementResult -> + + Either.cond(statementResult.summary().counters().relationshipsCreated() == 1, + ifTrue = { Unit }, + ifFalse = { NotCreatedError(relationType.name, "$fromId -> $toId") }) + } + }) + } + } + + // If relation does not exists, then it creates new relation. Or else updates it. + fun createOrUpdate(fromId: String, relation: RELATION, toId: String, transaction: Transaction): Either { + + return (relationType.from.entityStore?.exists(fromId, transaction) + ?: NotFoundError(type = relationType.from.name, id = fromId).left()) + .flatMap { + relationType.to.entityStore?.exists(toId, transaction) + ?: NotFoundError(type = relationType.to.name, id = toId).left() + }.flatMap { + + val properties = getProperties(relation as Any) + doNotExist(fromId, toId, transaction).fold( + { + // TODO vihang: replace setClause with map based settings written by Kjell + val setClause: String = properties.entries.fold("") { acc, entry -> """$acc SET r.`${entry.key}` = '${entry.value}' """ } + write( + """MATCH (fromId:${relationType.from.name} {id: '$fromId'})-[r:${relationType.name}]->(toId:${relationType.to.name} {id: '$toId'}) + $setClause ;""".trimMargin(), + transaction) { statementResult -> + Either.cond( + test = statementResult.summary().counters().containsUpdates(), // TODO vihang: this is not perfect way to check if updates are applied + ifTrue = {}, + ifFalse = { NotUpdatedError(type = relationType.name, id = "$fromId -> $toId") }) + } + }, + { + val strProps: String = properties.entries.joinToString(",") { """`${it.key}`: "${it.value}"""" } + + write(""" + MATCH (fromId:${relationType.from.name} {id: '$fromId'}),(toId:${relationType.to.name} {id: '$toId'}) + MERGE (fromId)-[:${relationType.name} { $strProps } ]->(toId) + """.trimMargin(), + transaction) { statementResult -> + + Either.cond(statementResult.summary().counters().relationshipsCreated() == 1, + ifTrue = { Unit }, + ifFalse = { NotCreatedError(relationType.name, "$fromId -> $toId") }) + } + }) + } + } + + // If relation exists, then it fails with Already Exists Error, else it creates new relation. + fun create(fromId: String, toId: String, transaction: Transaction): Either { + + return doNotExist(fromId, toId, transaction).flatMap { + write(""" + MATCH (fromId:${relationType.from.name} {id: '$fromId'}),(toId:${relationType.to.name} {id: '$toId'}) + MERGE (fromId)-[:${relationType.name}]->(toId) + """.trimMargin(), + transaction) { statementResult -> + + Either.cond(statementResult.summary().counters().relationshipsCreated() == 1, + ifTrue = { Unit }, + ifFalse = { NotCreatedError(relationType.name, "$fromId -> $toId") }) + } + } + } + + // If relation exists, then it fails with Already Exists Error, else it creates new relation. + fun create(fromId: String, relation: RELATION, toId: String, transaction: Transaction): Either { + + return doNotExist(fromId, toId, transaction).flatMap { + val properties = getProperties(relation as Any) + val strProps: String = properties.entries.joinToString(",") { """`${it.key}`: "${it.value}"""" } + + write(""" + MATCH (fromId:${relationType.from.name} {id: '$fromId'}),(toId:${relationType.to.name} {id: '$toId'}) + MERGE (fromId)-[:${relationType.name} { $strProps } ]->(toId) + """.trimMargin(), + transaction) { statementResult -> + + Either.cond(statementResult.summary().counters().relationshipsCreated() == 1, + ifTrue = { Unit }, + ifFalse = { NotCreatedError(relationType.name, "$fromId -> $toId") }) + } + } + } + + fun delete(fromId: String, toId: String, transaction: Transaction): Either = write(""" + MATCH (:${relationType.from.name} { id: '$fromId'})-[r:${relationType.name}]->(:${relationType.to.name} {id: '$toId'}) + DELETE r + """.trimMargin(), + transaction) { statementResult -> + + Either.cond(statementResult.summary().counters().relationshipsDeleted() == 1, + ifTrue = { Unit }, + ifFalse = { NotDeletedError(relationType.name, "$fromId -> $toId") }) + } + + fun get(fromId: String, toId: String, transaction: Transaction): Either = read(""" + MATCH (:${relationType.from.name} {id: '$fromId'})-[r:${relationType.name}]->(:${relationType.to.name} {id: '$toId'}) + RETURN r + """.trimMargin(), + transaction) { statementResult -> + + Either.cond(statementResult.hasNext(), + ifTrue = { relationType.createRelation(statementResult.single()["r"].asMap()) }, + ifFalse = { NotFoundError(relationType.name, "$fromId -> $toId") }) + .flatMap { + relation -> relation?.right() ?: NotFoundError(relationType.name, "$fromId -> $toId").left() + } + } + + private fun doNotExist(fromId: String, toId: String, transaction: Transaction): Either = read(""" + MATCH (:${relationType.from.name} {id: '$fromId'})-[r:${relationType.name}]->(:${relationType.to.name} {id: '$toId'}) + RETURN count(r) + """.trimMargin(), + transaction) { statementResult -> + + Either.cond( + test = statementResult.single()["count(r)"].asInt(1) == 0, + ifTrue = {}, + ifFalse = { AlreadyExistsError(type = relationType.name, id = "$fromId -> $toId") }) + } } @@ -315,24 +541,49 @@ object Graph { LOG.trace("read:[\n$query\n]") return transaction.run(query).let(transform) } + + suspend fun writeSuspended(query: String, transaction: Transaction, transform: (CompletionStage) -> R) { + LOG.trace("write:[\n$query\n]") + withContext(Dispatchers.Default) { + transaction.runAsync(query) + }.let(transform) + } } fun readTransaction(action: ReadTransaction.() -> R): R = Neo4jClient.driver.session(READ) .use { session -> - session.readTransaction { - action(ReadTransaction(PrimeTransaction(it))) + session.readTransaction { transaction -> + val primeTransaction = PrimeTransaction(transaction) + val result = action(ReadTransaction(primeTransaction)) + primeTransaction.close() + result } } fun writeTransaction(action: WriteTransaction.() -> R): R = Neo4jClient.driver.session(WRITE) .use { session -> - session.writeTransaction { - action(WriteTransaction(PrimeTransaction(it))) + session.writeTransaction { transaction -> + val primeTransaction = PrimeTransaction(transaction) + val result = action(WriteTransaction(primeTransaction)) + primeTransaction.close() + result } } +suspend fun suspendedWriteTransaction(action: suspend WriteTransaction.() -> R): R = + Neo4jClient.driver.session(WRITE) + .use { session -> + val transaction = session.beginTransaction() + val primeTransaction = PrimeTransaction(transaction) + val result = action(WriteTransaction(primeTransaction)) + primeTransaction.success() + primeTransaction.close() + transaction.close() + result + } + data class ReadTransaction(val transaction: PrimeTransaction) data class WriteTransaction(val transaction: PrimeTransaction) @@ -343,21 +594,20 @@ object ObjectHandler { private const val SEPARATOR = '/' - private val objectMapper = jacksonObjectMapper() - // // Object to Map // fun getProperties(any: Any): Map = toSimpleMap( - objectMapper.convertValue(any, object : TypeReference>() {})) + objectMapper.convertValue(any, object : TypeReference>() {})) - private fun toSimpleMap(map: Map, prefix: String = ""): Map { + private fun toSimpleMap(map: Map, prefix: String = ""): Map { val outputMap: MutableMap = LinkedHashMap() map.forEach { key, value -> when (value) { is Map<*, *> -> outputMap.putAll(toSimpleMap(value as Map, "$prefix$key$SEPARATOR")) is List<*> -> println("Skipping list value: $value for key: $key") + null -> Unit else -> outputMap["$prefix$key"] = value } } @@ -378,7 +628,7 @@ object ObjectHandler { if (key.contains(SEPARATOR)) { val keys = key.split(SEPARATOR) var loopMap = outputMap - for (i in 0..(keys.size - 2)) { + repeat(keys.size - 1) { i -> loopMap.putIfAbsent(keys[i], LinkedHashMap()) loopMap = loopMap[keys[i]] as MutableMap } @@ -390,4 +640,7 @@ object ObjectHandler { } return outputMap } -} \ No newline at end of file +} + +// Need a dummy Void class with no-arg constructor to represent Relations with no properties. +class None \ No newline at end of file diff --git a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/GraphStoreTest.kt b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/GraphStoreTest.kt deleted file mode 100644 index afb070bac..000000000 --- a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/GraphStoreTest.kt +++ /dev/null @@ -1,249 +0,0 @@ -package org.ostelco.prime.storage.graph - -import com.palantir.docker.compose.DockerComposeRule -import com.palantir.docker.compose.connection.waiting.HealthChecks -import org.joda.time.Duration -import org.junit.AfterClass -import org.junit.BeforeClass -import org.junit.ClassRule -import org.mockito.Mockito -import org.neo4j.driver.v1.AccessMode.WRITE -import org.ostelco.prime.model.Offer -import org.ostelco.prime.model.Price -import org.ostelco.prime.model.Product -import org.ostelco.prime.model.PurchaseRecord -import org.ostelco.prime.model.Segment -import org.ostelco.prime.model.Subscriber -import org.ostelco.prime.model.Subscription -import org.ostelco.prime.ocs.OcsAdminService -import java.time.Instant -import java.util.* -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.test.fail - -class MockOcsAdminService : OcsAdminService by Mockito.mock(OcsAdminService::class.java) - -class GraphStoreTest { - - @BeforeTest - fun clear() { - - Neo4jClient.driver.session(WRITE).use { session -> - session.writeTransaction { - it.run("MATCH (n) DETACH DELETE n") - } - } - - Neo4jStoreSingleton.createProduct( - Product(sku = "100MB_FREE_ON_JOINING", - price = Price(0, CURRENCY), - properties = mapOf("noOfBytes" to "100_000_000"))) - - Neo4jStoreSingleton.createProduct( - Product(sku = "1GB_FREE_ON_REFERRED", - price = Price(0, CURRENCY), - properties = mapOf("noOfBytes" to "1_000_000_000"))) - - val allSegment = Segment(id = getSegmentNameFromCountryCode(COUNTRY)) - Neo4jStoreSingleton.createSegment(allSegment) - } - - @Test - fun `add subscriber`() { - - Neo4jStoreSingleton.addSubscriber(Subscriber(email = EMAIL, name = NAME, country = COUNTRY), referredBy = null) - .mapLeft { fail(it.message) } - - Neo4jStoreSingleton.getSubscriber(EMAIL).bimap( - { fail(it.message) }, - { assertEquals(Subscriber(email = EMAIL, name = NAME, referralId = EMAIL, country = COUNTRY), it) }) - - // TODO vihang: fix argument captor for neo4j-store tests -// val bundleArgCaptor: ArgumentCaptor = ArgumentCaptor.forClass(Bundle::class.java) -// verify(OCS_MOCK, times(1)).addBundle(bundleArgCaptor.capture()) -// assertEquals(Bundle(id = EMAIL, balance = 100_000_000), bundleArgCaptor.value) - } - - @Test - fun `fail to add subscriber with invalid referred by`() { - - Neo4jStoreSingleton.addSubscriber(Subscriber(email = EMAIL, name = NAME, country = COUNTRY), referredBy = "blah") - .fold({ - assertEquals( - expected = "Failed to create REFERRED - blah -> foo@bar.com", - actual = it.message) - }, - { fail("Created subscriber in spite of invalid 'referred by'") }) - } - - @Test - fun `add subscription`() { - - Neo4jStoreSingleton.addSubscriber(Subscriber(email = EMAIL, name = NAME, country = COUNTRY), referredBy = null) - .mapLeft { fail(it.message) } - - Neo4jStoreSingleton.addSubscription(EMAIL, MSISDN) - .mapLeft { fail(it.message) } - - Neo4jStoreSingleton.getMsisdn(EMAIL).bimap( - { fail(it.message) }, - { assertEquals(MSISDN, it) }) - - Neo4jStoreSingleton.getSubscriptions(EMAIL).bimap( - { fail(it.message) }, - { assertEquals(listOf(Subscription(MSISDN)), it) }) - - // TODO vihang: fix argument captor for neo4j-store tests -// val msisdnArgCaptor: ArgumentCaptor = ArgumentCaptor.forClass(String::class.java) -// val bundleIdArgCaptor: ArgumentCaptor = ArgumentCaptor.forClass(String::class.java) -// verify(OCS_MOCK).addMsisdnToBundleMapping(msisdnArgCaptor.capture(), bundleIdArgCaptor.capture()) -// assertEquals(MSISDN, msisdnArgCaptor.value) -// assertEquals(EMAIL, bundleIdArgCaptor.value) - } - - @Test - fun `set and get Purchase record`() { - assert(Neo4jStoreSingleton.addSubscriber(Subscriber(email = EMAIL, name = NAME, country = COUNTRY), referredBy = null).isRight()) - - val product = createProduct("1GB_249NOK", 24900) - val now = Instant.now().toEpochMilli() - - Neo4jStoreSingleton.createProduct(product) - .mapLeft { fail(it.message) } - - val purchaseRecord = PurchaseRecord(product = product, timestamp = now, id = UUID.randomUUID().toString(), msisdn = "") - Neo4jStoreSingleton.addPurchaseRecord(EMAIL, purchaseRecord).bimap( - { fail(it.message) }, - { assertNotNull(it) } - ) - - Neo4jStoreSingleton.getPurchaseRecords(EMAIL).bimap( - { fail(it.message) }, - { assertTrue(it.contains(purchaseRecord)) } - ) - } - - @Test - fun `create products, offer, segment and then get products for a subscriber`() { - assert(Neo4jStoreSingleton.addSubscriber(Subscriber(email = EMAIL, name = NAME, country = COUNTRY), referredBy = null).isRight()) - - Neo4jStoreSingleton.createProduct(createProduct("1GB_249NOK", 24900)) - Neo4jStoreSingleton.createProduct(createProduct("2GB_299NOK", 29900)) - Neo4jStoreSingleton.createProduct(createProduct("3GB_349NOK", 34900)) - Neo4jStoreSingleton.createProduct(createProduct("5GB_399NOK", 39900)) - - val segment = Segment( - id = "NEW_SEGMENT", - subscribers = listOf(EMAIL)) - Neo4jStoreSingleton.createSegment(segment) - - val offer = Offer( - id = "NEW_OFFER", - segments = listOf("NEW_SEGMENT"), - products = listOf("3GB_349NOK")) - Neo4jStoreSingleton.createOffer(offer) - - Neo4jStoreSingleton.getProducts(EMAIL).bimap( - { fail(it.message) }, - { products -> - assertEquals(1, products.size) - assertEquals(createProduct("3GB_349NOK", 34900), products.values.first()) - }) - - Neo4jStoreSingleton.getProduct(EMAIL, "2GB_299NOK").bimap( - { assertEquals("Product - 2GB_299NOK not found.", it.message) }, - { fail("Expected get product to fail since it is not linked to any subscriber --> segment --> offer") }) - } - - @Test - fun `import offer + product + segment`() { - - // existing products - Neo4jStoreSingleton.createProduct(createProduct("1GB_249NOK", 24900)) - .mapLeft { fail(it.message) } - Neo4jStoreSingleton.createProduct(createProduct("2GB_299NOK", 29900)) - .mapLeft { fail(it.message) } - - val products = listOf( - createProduct("3GB_349NOK", 34900), - createProduct("5GB_399NOK", 39900)) - - val segments = listOf(Segment(id = "segment_1"), Segment(id = "segment_2")) - - val offer = Offer(id = "some_offer", products = listOf("1GB_249NOK", "2GB_299NOK")) - - Neo4jStoreSingleton.atomicCreateOffer(offer = offer, products = products, segments = segments) - .mapLeft { fail(it.message) } - } - - @Test - fun `failed on import duplicate offer`() { - - // existing products - Neo4jStoreSingleton.createProduct(createProduct("1GB_249NOK", 24900)) - .mapLeft { fail(it.message) } - Neo4jStoreSingleton.createProduct(createProduct("2GB_299NOK", 29900)) - .mapLeft { fail(it.message) } - - // new products in the offer - val products = listOf( - createProduct("3GB_349NOK", 34900), - createProduct("5GB_399NOK", 39900)) - - // new segment in the offer - val segments = listOf(Segment(id = "segment_1"), Segment(id = "segment_2")) - - val offer = Offer(id = "some_offer", products = listOf("1GB_249NOK", "2GB_299NOK")) - - Neo4jStoreSingleton.atomicCreateOffer(offer = offer, products = products, segments = segments) - .mapLeft { fail(it.message) } - - val duplicateOffer = Offer( - id = offer.id, - products = (products.map { it.sku } + offer.products).toSet(), - segments = segments.map { it.id }) - - Neo4jStoreSingleton.atomicCreateOffer(offer = duplicateOffer).bimap( - { assertEquals("Offer - some_offer already exists.", it.message) }, - { fail("Expected import to fail since offer already exists.") }) - } - - companion object { - const val EMAIL = "foo@bar.com" - const val NAME = "Test User" - const val CURRENCY = "NOK" - const val COUNTRY = "NO" - const val MSISDN = "4712345678" - - @ClassRule - @JvmField - var docker: DockerComposeRule = DockerComposeRule.builder() - .file("src/test/resources/docker-compose.yaml") - .waitingForService("neo4j", HealthChecks.toHaveAllPortsOpen()) - .waitingForService("neo4j", - HealthChecks.toRespond2xxOverHttp(7474) { port -> - port.inFormat("http://\$HOST:\$EXTERNAL_PORT/browser") - }, - Duration.standardSeconds(40L)) - .build() - - @BeforeClass - @JvmStatic - fun start() { - ConfigRegistry.config = Config() - ConfigRegistry.config.host = "0.0.0.0" - ConfigRegistry.config.protocol = "bolt" - Neo4jClient.start() - } - - @AfterClass - @JvmStatic - fun stop() { - Neo4jClient.stop() - } - } -} \ No newline at end of file diff --git a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jLoadTest.kt b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jLoadTest.kt new file mode 100644 index 000000000..b30c3e718 --- /dev/null +++ b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jLoadTest.kt @@ -0,0 +1,162 @@ +package org.ostelco.prime.storage.graph + +import com.palantir.docker.compose.DockerComposeRule +import com.palantir.docker.compose.connection.waiting.HealthChecks +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.joda.time.Duration +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.ClassRule +import org.neo4j.driver.v1.AccessMode.WRITE +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.Price +import org.ostelco.prime.model.Product +import org.ostelco.prime.model.Segment +import java.time.Instant +import java.util.concurrent.CountDownLatch +import kotlin.test.BeforeTest +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +class Neo4jLoadTest { + + @BeforeTest + fun clear() { + + Neo4jClient.driver.session(WRITE).use { session -> + session.writeTransaction { + it.run("MATCH (n) DETACH DELETE n") + } + } + + Neo4jStoreSingleton.createIndex() + + Neo4jStoreSingleton.createProduct( + Product(sku = "2GB_FREE_ON_JOINING", + price = Price(0, ""), + properties = mapOf("noOfBytes" to "2_147_483_648"))) + + Neo4jStoreSingleton.createProduct( + Product(sku = "1GB_FREE_ON_REFERRED", + price = Price(0, ""), + properties = mapOf("noOfBytes" to "1_000_000_000"))) + + val allSegment = Segment(id = getSegmentNameFromCountryCode(COUNTRY)) + Neo4jStoreSingleton.createSegment(allSegment) + } + + @Ignore + @Test + fun `load test Neo4j`() { + + println("Provisioning test users") + + repeat(USERS) { user -> + Neo4jStoreSingleton.addCustomer( + identity = Identity(id = "test-$user", type = "EMAIL", provider = "email"), + customer = Customer(contactEmail = "test-$user@ostelco.org", nickname = NAME)) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.addSubscription( + identity = Identity(id = "test-$user", type = "EMAIL", provider = "email"), + msisdn = "$user") + .mapLeft { fail(it.message) } + } + + // balance = 100_000_000 + // reserved = 0 + + // requested = 100 + // used = 10 + + // Start timestamp in millisecond + val start = Instant.now() + + val cdl = CountDownLatch(COUNT) + + runBlocking(Dispatchers.Default) { + repeat(COUNT) { i -> + launch { + Neo4jStoreSingleton.consume(msisdn = "${i % USERS}", usedBytes = USED, requestedBytes = REQUESTED) { storeResult -> + storeResult.fold( + { fail(it.message) }, + { + // println("Balance = %,d, Granted = %,d".format(it.second, it.first)) + cdl.countDown() + assert(true) + }) + } + } + } + + // Wait for all the responses to be returned + println("Waiting for all responses to be returned") + } + + cdl.await() + + // Stop timestamp in millisecond + val stop = Instant.now() + + // Print load test results + val diff = stop.toEpochMilli() - start.toEpochMilli() + println("Time diff: %,d milli sec".format(diff)) + val rate = COUNT * 1000.0 / diff + println("Rate: %,.2f req/sec".format(rate)) + + Neo4jStoreSingleton.getBundles(identity = Identity(id = "test-0", type = "EMAIL", provider = "email")) + .fold( + { fail(it.message) }, + { + assertEquals(expected = 100_000_000 - COUNT / USERS * USED - REQUESTED, + actual = it.single().balance, + message = "Balance does not match") + } + ) + } + + companion object { + + const val COUNT = 10_000 + const val USERS = 100 + + const val USED = 10L + const val REQUESTED = 100L + + const val NAME = "Test User" + const val CURRENCY = "NOK" + const val COUNTRY = "NO" + + @ClassRule + @JvmField + var docker: DockerComposeRule = DockerComposeRule.builder() + .file("src/test/resources/docker-compose.yaml") + .waitingForService("neo4j", HealthChecks.toHaveAllPortsOpen()) + .waitingForService("neo4j", + HealthChecks.toRespond2xxOverHttp(7474) { port -> + port.inFormat("http://\$HOST:\$EXTERNAL_PORT/browser") + }, + Duration.standardSeconds(40L)) + .build() + + @BeforeClass + @JvmStatic + fun start() { + ConfigRegistry.config = Config( + host = "0.0.0.0", + protocol = "bolt") + Neo4jClient.start() + } + + @AfterClass + @JvmStatic + fun stop() { + Neo4jClient.stop() + } + } +} \ No newline at end of file diff --git a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt new file mode 100644 index 000000000..44608f2d3 --- /dev/null +++ b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt @@ -0,0 +1,976 @@ +package org.ostelco.prime.storage.graph + +import arrow.core.right +import com.palantir.docker.compose.DockerComposeRule +import com.palantir.docker.compose.connection.waiting.HealthChecks +import kotlinx.coroutines.runBlocking +import org.joda.time.Duration +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.ClassRule +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.neo4j.driver.v1.AccessMode.WRITE +import org.ostelco.prime.analytics.AnalyticsService +import org.ostelco.prime.appnotifier.AppNotifier +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.CustomerRegionStatus.APPROVED +import org.ostelco.prime.model.CustomerRegionStatus.PENDING +import org.ostelco.prime.model.Identity +import org.ostelco.prime.model.JumioScanData +import org.ostelco.prime.model.KycStatus +import org.ostelco.prime.model.KycType.ADDRESS_AND_PHONE_NUMBER +import org.ostelco.prime.model.KycType.JUMIO +import org.ostelco.prime.model.KycType.MY_INFO +import org.ostelco.prime.model.KycType.NRIC_FIN +import org.ostelco.prime.model.Offer +import org.ostelco.prime.model.Price +import org.ostelco.prime.model.Product +import org.ostelco.prime.model.PurchaseRecord +import org.ostelco.prime.model.Region +import org.ostelco.prime.model.RegionDetails +import org.ostelco.prime.model.ScanInformation +import org.ostelco.prime.model.ScanResult +import org.ostelco.prime.model.ScanStatus +import org.ostelco.prime.model.Segment +import org.ostelco.prime.model.SimEntry +import org.ostelco.prime.model.SimProfile +import org.ostelco.prime.model.SimProfileStatus.AVAILABLE_FOR_DOWNLOAD +import org.ostelco.prime.notifications.EmailNotifier +import org.ostelco.prime.paymentprocessor.PaymentProcessor +import org.ostelco.prime.paymentprocessor.core.ProfileInfo +import org.ostelco.prime.sim.SimManager +import org.ostelco.prime.storage.NotFoundError +import org.ostelco.prime.storage.ScanInformationStore +import java.time.Instant +import java.util.* +import javax.ws.rs.core.MultivaluedHashMap +import javax.ws.rs.core.MultivaluedMap +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +private val mockPaymentProcessor = Mockito.mock(PaymentProcessor::class.java) + +class MockPaymentProcessor : PaymentProcessor by mockPaymentProcessor + +class MockAnalyticsService : AnalyticsService by Mockito.mock(AnalyticsService::class.java) + +private val mockScanInformationStore = Mockito.mock(ScanInformationStore::class.java) + +class MockScanInformationStore : ScanInformationStore by mockScanInformationStore + +private val mockSimManager = Mockito.mock(SimManager::class.java) + +class MockSimManager : SimManager by mockSimManager + +private val mockEmailNotifier = Mockito.mock(EmailNotifier::class.java) + +class MockEmailNotifier : EmailNotifier by mockEmailNotifier + +private val mockAppNotifier = Mockito.mock(AppNotifier::class.java) + +class MockAppNotifier : AppNotifier by mockAppNotifier + +class Neo4jStoreTest { + + @BeforeTest + fun clear() { + + Neo4jClient.driver.session(WRITE).use { session -> + session.writeTransaction { + it.run("MATCH (n) DETACH DELETE n") + } + } + + Neo4jStoreSingleton.createProduct( + Product(sku = "2GB_FREE_ON_JOINING", + price = Price(0, ""), + properties = mapOf("noOfBytes" to "2_147_483_648"))) + + Neo4jStoreSingleton.createProduct( + Product(sku = "1GB_FREE_ON_REFERRED", + price = Price(0, ""), + properties = mapOf("noOfBytes" to "1_073_741_824"))) + + val allSegment = Segment(id = getSegmentNameFromCountryCode(REGION)) + Neo4jStoreSingleton.createSegment(allSegment) + } + + @Test + fun `test - add customer`() { + + Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER, + referredBy = null) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.getCustomer(IDENTITY).bimap( + { fail(it.message) }, + { assertEquals(CUSTOMER, it) }) + + // TODO vihang: fix argument captor for neo4j-store tests +// val bundleArgCaptor: ArgumentCaptor = ArgumentCaptor.forClass(Bundle::class.java) +// verify(OCS_MOCK, times(1)).addBundle(bundleArgCaptor.capture()) +// assertEquals(Bundle(id = EMAIL, balance = 100_000_000), bundleArgCaptor.value) + } + + @Test + fun `test - fail to add customer with invalid referred by`() { + + Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER, referredBy = "blah") + .fold({ + assertEquals( + expected = "Failed to create REFERRED - blah -> ${CUSTOMER.id}", + actual = it.message) + }, + { fail("Created customer in spite of invalid 'referred by'") }) + } + + @Test + fun `test - add subscription`() { + + Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.addSubscription(identity = IDENTITY, msisdn = MSISDN) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.getSubscriptions(IDENTITY).bimap( + { fail(it.message) }, + { assertEquals(MSISDN, it.single().msisdn) }) + + // TODO vihang: fix argument captor for neo4j-store tests +// val msisdnArgCaptor: ArgumentCaptor = ArgumentCaptor.forClass(String::class.java) +// val bundleIdArgCaptor: ArgumentCaptor = ArgumentCaptor.forClass(String::class.java) +// verify(OCS_MOCK).addMsisdnToBundleMapping(msisdnArgCaptor.capture(), bundleIdArgCaptor.capture()) +// assertEquals(MSISDN, msisdnArgCaptor.value) +// assertEquals(EMAIL, bundleIdArgCaptor.value) + } + + @Test + fun `test - purchase`() { + + val sku = "1GB_249NOK" + val chargeId = UUID.randomUUID().toString() + // mock + Mockito.`when`(mockPaymentProcessor.getPaymentProfile(customerId = CUSTOMER.id)) + .thenReturn(ProfileInfo(EMAIL).right()) + + Mockito.`when`(mockPaymentProcessor.authorizeCharge( + customerId = EMAIL, + sourceId = null, + amount = 24900, + currency = "NOK") + ).thenReturn(chargeId.right()) + + Mockito.`when`(mockPaymentProcessor.captureCharge( + customerId = EMAIL, + amount = 24900, + currency = "NOK", + chargeId = chargeId) + ).thenReturn(chargeId.right()) + + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.createProduct(createProduct(sku = sku, amount = 24900)) + .mapLeft { fail(it.message) } + + val offer = Offer( + id = "NEW_OFFER", + segments = listOf(getSegmentNameFromCountryCode(REGION)), + products = listOf(sku)) + + Neo4jStoreSingleton.createOffer(offer) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.addSubscription(IDENTITY, MSISDN) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = APPROVED, + regionCode = "no") + + // test + Neo4jStoreSingleton.purchaseProduct(identity = IDENTITY, sku = sku, sourceId = null, saveCard = false) + .mapLeft { fail(it.description) } + + // assert + Neo4jStoreSingleton.getBundles(IDENTITY).bimap( + { fail(it.message) }, + { bundles -> + bundles.forEach { bundle -> + assertEquals(3_221_225_472L, bundle.balance) + } + }) + } + + @Test + fun `test - consume`() = runBlocking { + Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.addSubscription(IDENTITY, MSISDN) + .mapLeft { fail(it.message) } + + // balance = 100_000_000 + // reserved = 0 + + // requested = 40_000_000 + val dataBucketSize = 40_000_000L + Neo4jStoreSingleton.consume(msisdn = MSISDN, usedBytes = 0, requestedBytes = dataBucketSize) { storeResult -> + storeResult.fold( + { fail(it.message) }, + { + assertEquals(dataBucketSize, it.granted) // reserved = 40_000_000 + assertEquals(60_000_000L, it.balance) // balance = 60_000_000 + }) + } + // used = 50_000_000 + // requested = 40_000_000 + Neo4jStoreSingleton.consume(msisdn = MSISDN, usedBytes = 50_000_000L, requestedBytes = dataBucketSize) { storeResult -> + storeResult.fold( + { fail(it.message) }, + { + assertEquals(dataBucketSize, it.granted) // reserved = 40_000_000 + assertEquals(10_000_000L, it.balance) // balance = 10_000_000 + }) + } + + // used = 30_000_000 + // requested = 40_000_000 + Neo4jStoreSingleton.consume(msisdn = MSISDN, usedBytes = 30_000_000L, requestedBytes = dataBucketSize) { storeResult -> + storeResult.fold( + { fail(it.message) }, + { + assertEquals(20_000_000L, it.granted) // reserved = 20_000_000 + assertEquals(0L, it.balance) // balance = 0 + }) + } + } + + @Test + fun `set and get Purchase record`() { + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + val product = createProduct("1GB_249NOK", 24900) + val now = Instant.now().toEpochMilli() + + Neo4jStoreSingleton.createProduct(product) + .mapLeft { fail(it.message) } + + val purchaseRecord = PurchaseRecord(product = product, timestamp = now, id = UUID.randomUUID().toString()) + Neo4jStoreSingleton.addPurchaseRecord(customerId = CUSTOMER.id, purchase = purchaseRecord).bimap( + { fail(it.message) }, + { assertNotNull(it) } + ) + + Neo4jStoreSingleton.getPurchaseRecords(IDENTITY).bimap( + { fail(it.message) }, + { assertTrue(it.contains(purchaseRecord)) } + ) + } + + @Test + fun `create products, offer, segment and then get products for a customer`() { + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + Neo4jStoreSingleton.createProduct(createProduct("1GB_249NOK", 24900)) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createProduct(createProduct("2GB_299NOK", 29900)) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createProduct(createProduct("3GB_349NOK", 34900)) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createProduct(createProduct("5GB_399NOK", 39900)) + .mapLeft { fail(it.message) } + + val segment = Segment( + id = "NEW_SEGMENT", + subscribers = listOf(CUSTOMER.id)) + Neo4jStoreSingleton.createSegment(segment) + .mapLeft { fail(it.message) } + + val offer = Offer( + id = "NEW_OFFER", + segments = listOf("NEW_SEGMENT"), + products = listOf("3GB_349NOK")) + Neo4jStoreSingleton.createOffer(offer) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.getProducts(IDENTITY).bimap( + { fail(it.message) }, + { products -> + assertEquals(1, products.size) + assertEquals(createProduct("3GB_349NOK", 34900), products.values.first()) + }) + + Neo4jStoreSingleton.getProduct(IDENTITY, "2GB_299NOK").bimap( + { assertEquals("Product - 2GB_299NOK not found.", it.message) }, + { fail("Expected get product to fail since it is not linked to any subscriber --> segment --> offer") }) + } + + @Test + fun `import offer + product + segment`() { + + // existing products + Neo4jStoreSingleton.createProduct(createProduct("1GB_249NOK", 24900)) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createProduct(createProduct("2GB_299NOK", 29900)) + .mapLeft { fail(it.message) } + + val products = listOf( + createProduct("3GB_349NOK", 34900), + createProduct("5GB_399NOK", 39900)) + + val segments = listOf(Segment(id = "segment_1"), Segment(id = "segment_2")) + + val offer = Offer(id = "some_offer", products = listOf("1GB_249NOK", "2GB_299NOK")) + + Neo4jStoreSingleton.atomicCreateOffer(offer = offer, products = products, segments = segments) + .mapLeft { fail(it.message) } + } + + @Test + fun `failed on import duplicate offer`() { + + // existing products + Neo4jStoreSingleton.createProduct(createProduct("1GB_249NOK", 24900)) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createProduct(createProduct("2GB_299NOK", 29900)) + .mapLeft { fail(it.message) } + + // new products in the offer + val products = listOf( + createProduct("3GB_349NOK", 34900), + createProduct("5GB_399NOK", 39900)) + + // new segment in the offer + val segments = listOf(Segment(id = "segment_1"), Segment(id = "segment_2")) + + val offer = Offer(id = "some_offer", products = listOf("1GB_249NOK", "2GB_299NOK")) + + Neo4jStoreSingleton.atomicCreateOffer(offer = offer, products = products, segments = segments) + .mapLeft { fail(it.message) } + + val duplicateOffer = Offer( + id = offer.id, + products = (products.map { it.sku } + offer.products).toSet(), + segments = segments.map { it.id }) + + Neo4jStoreSingleton.atomicCreateOffer(offer = duplicateOffer).bimap( + { assertEquals("Offer - some_offer already exists.", it.message) }, + { fail("Expected import to fail since offer already exists.") }) + } + + @Test + fun `eKYCScan - generate new scanId`() { + + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + // test + Neo4jStoreSingleton.createNewJumioKycScanId(identity = IDENTITY, regionCode = REGION).map { + Neo4jStoreSingleton.getScanInformation(identity = IDENTITY, scanId = it.scanId).mapLeft { + fail(it.message) + } + }.mapLeft { + fail(it.message) + } + } + + @Test + fun `eKYCScan - get all scans`() { + + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + // test + Neo4jStoreSingleton.createNewJumioKycScanId(identity = IDENTITY, regionCode = REGION).map { newScan -> + Neo4jStoreSingleton.getAllScanInformation(identity = IDENTITY).map { infoList -> + assertEquals(1, infoList.size, "More scans than expected.") + assertEquals(newScan.scanId, infoList.elementAt(0).scanId, "Wrong scan returned.") + }.mapLeft { + fail(it.message) + } + }.mapLeft { + fail(it.message) + } + } + + @Test + fun `eKYCScan - update scan information`() { + + assert(Neo4jStoreSingleton.createRegion(Region(id = "no", name = "Norway")).isRight()) + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + Neo4jStoreSingleton.createNewJumioKycScanId(identity = IDENTITY, regionCode = REGION).map { + val newScanInformation = ScanInformation( + scanId = it.scanId, + countryCode = REGION, + status = ScanStatus.APPROVED, + scanResult = ScanResult( + vendorScanReference = UUID.randomUUID().toString(), + time = 100, + verificationStatus = "APPROVED", + type = "ID", + country = "NOR", + firstName = "Test User", + lastName = "Family", + dob = "1980/10/10", + rejectReason = null + ) + ) + val vendorData: MultivaluedMap = MultivaluedHashMap() + val scanId = "id1" + val imgUrl = "https://www.gstatic.com/webp/gallery3/1.png" + val imgUrl2 = "https://www.gstatic.com/webp/gallery3/2.png" + vendorData.add(JumioScanData.SCAN_ID.s, scanId) + vendorData.add(JumioScanData.SCAN_IMAGE.s, imgUrl) + vendorData.add(JumioScanData.SCAN_IMAGE_BACKSIDE.s, imgUrl2) + + Mockito.`when`(mockScanInformationStore.upsertVendorScanInformation(customerId = CUSTOMER.id, countryCode = REGION, vendorData = vendorData)) + .thenReturn(Unit.right()) + + Neo4jStoreSingleton.updateScanInformation(newScanInformation, vendorData).mapLeft { + fail(it.message) + } + }.mapLeft { + fail(it.message) + } + } + + @Test + fun `eKYCScan - update with unknown scanId`() { + + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + // test + Neo4jStoreSingleton.createNewJumioKycScanId(identity = IDENTITY, regionCode = REGION).map { + val newScanInformation = ScanInformation( + scanId = "fakeId", + countryCode = REGION, + status = ScanStatus.APPROVED, + scanResult = ScanResult( + vendorScanReference = UUID.randomUUID().toString(), + time = 100, + verificationStatus = "APPROVED", + type = "ID", + country = "NOR", + firstName = "Test User", + lastName = "Family", + dob = "1980/10/10", + rejectReason = null + ) + ) + val vendorData: MultivaluedMap = MultivaluedHashMap() + val scanId = "id1" + val imgUrl = "https://www.gstatic.com/webp/gallery3/1.png" + val imgUrl2 = "https://www.gstatic.com/webp/gallery3/2.png" + vendorData.add(JumioScanData.SCAN_ID.s, scanId) + vendorData.add(JumioScanData.SCAN_IMAGE.s, imgUrl) + vendorData.add(JumioScanData.SCAN_IMAGE_BACKSIDE.s, imgUrl2) + Neo4jStoreSingleton.updateScanInformation(newScanInformation, vendorData).bimap( + { assertEquals("ScanInformation - fakeId not found.", it.message) }, + { fail("Expected to fail since scanId is fake.") }) + }.mapLeft { + fail(it.message) + } + } + + @Test + fun `eKYCScan - illegal access`() { + + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + val fakeEmail = "fake-$EMAIL" + val fakeIdentity = Identity(id = fakeEmail, type = "EMAIL", provider = "email") + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + assert(Neo4jStoreSingleton.addCustomer( + identity = fakeIdentity, + customer = Customer(contactEmail = fakeEmail, nickname = NAME)).isRight()) + + // test + Neo4jStoreSingleton.createNewJumioKycScanId(fakeIdentity, REGION).mapLeft { + fail(it.message) + } + Neo4jStoreSingleton.createNewJumioKycScanId(identity = IDENTITY, regionCode = REGION).map { + Neo4jStoreSingleton.getScanInformation(fakeIdentity, scanId = it.scanId).bimap( + { assertEquals("Not allowed", it.message) }, + { fail("Expected to fail since the requested subscriber is wrong.") }) + }.mapLeft { + fail(it.message) + } + } + + @Test + fun `test provision and get SIM profile`() { + + // prep + `when`(mockEmailNotifier.sendESimQrCodeEmail(email = CUSTOMER.contactEmail, name = CUSTOMER.nickname, qrCode = "eSimActivationCode")) + .thenReturn(Unit.right()) + + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = APPROVED, + regionCode = "no").isRight()) + + Mockito.`when`(mockSimManager.allocateNextEsimProfile("Loltel", "default")) + .thenReturn(SimEntry(iccId = "iccId", eSimActivationCode = "eSimActivationCode", msisdnList = emptyList(), status = AVAILABLE_FOR_DOWNLOAD).right()) + + Mockito.`when`(mockSimManager.getSimProfile("Loltel", "iccId")) + .thenReturn(SimEntry(iccId = "iccId", eSimActivationCode = "eSimActivationCode", msisdnList = emptyList(), status = AVAILABLE_FOR_DOWNLOAD).right()) + + // test + Neo4jStoreSingleton.provisionSimProfile( + identity = IDENTITY, + regionCode = "no", + profileType = "default") + .bimap( + { fail(it.message) }, + { + assertEquals( + expected = SimProfile( + iccId = "iccId", + eSimActivationCode = "eSimActivationCode", + status = AVAILABLE_FOR_DOWNLOAD), + actual = it) + }) + + Neo4jStoreSingleton.getSimProfiles( + identity = IDENTITY, + regionCode = "no") + .bimap( + { fail(it.message) }, + { + assertEquals( + expected = listOf(SimProfile( + iccId = "iccId", + eSimActivationCode = "eSimActivationCode", + status = AVAILABLE_FOR_DOWNLOAD)), + actual = it) + }) + } + + @Test + fun `test getAllRegionDetails with no region`() { + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + // test + Neo4jStoreSingleton.getAllRegionDetails(identity = IDENTITY) + .bimap( + { fail("Failed to fetch regions empty list") }, + { assert(it.isEmpty()) { "Regions list should be empty" } }) + } + + @Test + fun `test getRegionDetails with no region`() { + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + // test + Neo4jStoreSingleton.getRegionDetails(identity = IDENTITY, regionCode = "no") + .bimap( + { + assert(it is NotFoundError) + assertEquals(expected = "BELONG_TO_REGION", actual = it.type) + assertTrue { it.id.endsWith(" -> no") } + }, + { fail("Should fail with not found error") }) + } + + @Test + fun `test getAllRegionDetails with region without sim profile`() { + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createRegion(Region("sg", "Singapore")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = APPROVED, + regionCode = "no").isRight()) + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = PENDING, + regionCode = "sg").isRight()) + + // test + Neo4jStoreSingleton.getAllRegionDetails(identity = IDENTITY) + .bimap( + { fail("Failed to fetch regions list") }, + { + assertEquals( + expected = setOf( + RegionDetails( + region = Region("no", "Norway"), + kycStatusMap = mapOf(JUMIO to KycStatus.PENDING), + status = APPROVED), + RegionDetails( + region = Region("sg", "Singapore"), + kycStatusMap = mapOf( + JUMIO to KycStatus.PENDING, + MY_INFO to KycStatus.PENDING, + ADDRESS_AND_PHONE_NUMBER to KycStatus.PENDING, + NRIC_FIN to KycStatus.PENDING), + status = PENDING)), + actual = it.toSet()) + }) + } + + @Test + fun `test getRegionDetails with region without sim profile`() { + // prep + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createRegion(Region("sg", "Singapore")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = APPROVED, + regionCode = "no").isRight()) + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = PENDING, + regionCode = "sg").isRight()) + + // test + Neo4jStoreSingleton.getRegionDetails(identity = IDENTITY, regionCode = "no") + .bimap( + { fail("Failed to fetch regions list") }, + { + assertEquals( + expected = RegionDetails( + region = Region("no", "Norway"), + status = APPROVED, + kycStatusMap = mapOf(JUMIO to KycStatus.PENDING)), + actual = it) + }) + } + + @Test + fun `test getAllRegionDetails with region with sim profiles`() { + + // prep + `when`(mockEmailNotifier.sendESimQrCodeEmail(email = CUSTOMER.contactEmail, name = CUSTOMER.nickname, qrCode = "eSimActivationCode")) + .thenReturn(Unit.right()) + + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createRegion(Region("sg", "Singapore")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = APPROVED, + regionCode = "no").isRight()) + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = PENDING, + regionCode = "sg").isRight()) + + Mockito.`when`(mockSimManager.allocateNextEsimProfile("Loltel", "default")) + .thenReturn(SimEntry(iccId = "iccId", eSimActivationCode = "eSimActivationCode", msisdnList = emptyList(), status = AVAILABLE_FOR_DOWNLOAD).right()) + + Mockito.`when`(mockSimManager.getSimProfile("Loltel", "iccId")) + .thenReturn(SimEntry(iccId = "iccId", eSimActivationCode = "eSimActivationCode", msisdnList = emptyList(), status = AVAILABLE_FOR_DOWNLOAD).right()) + + assert(Neo4jStoreSingleton.provisionSimProfile( + identity = IDENTITY, + regionCode = "no", + profileType = "default").isRight()) + + // test + Neo4jStoreSingleton.getAllRegionDetails(identity = IDENTITY) + .bimap( + { fail("Failed to fetch regions list") }, + { + assertEquals( + expected = setOf( + RegionDetails( + region = Region("no", "Norway"), + status = APPROVED, + kycStatusMap = mapOf(JUMIO to KycStatus.PENDING), + simProfiles = listOf( + SimProfile( + iccId = "iccId", + eSimActivationCode = "eSimActivationCode", + status = AVAILABLE_FOR_DOWNLOAD))), + RegionDetails( + region = Region("sg", "Singapore"), + kycStatusMap = mapOf( + JUMIO to KycStatus.PENDING, + MY_INFO to KycStatus.PENDING, + ADDRESS_AND_PHONE_NUMBER to KycStatus.PENDING, + NRIC_FIN to KycStatus.PENDING), + status = PENDING)), + actual = it.toSet()) + }) + } + + @Test + fun `test getRegionDetails with region with sim profiles`() { + + // prep + `when`(mockEmailNotifier.sendESimQrCodeEmail(email = CUSTOMER.contactEmail, name = CUSTOMER.nickname, qrCode = "eSimActivationCode")) + .thenReturn(Unit.right()) + + Neo4jStoreSingleton.createRegion(Region("no", "Norway")) + .mapLeft { fail(it.message) } + Neo4jStoreSingleton.createRegion(Region("sg", "Singapore")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = APPROVED, + regionCode = "no").isRight()) + assert(Neo4jStoreSingleton.createCustomerRegionSetting( + customerId = CUSTOMER.id, + status = PENDING, + regionCode = "sg").isRight()) + + Mockito.`when`(mockSimManager.allocateNextEsimProfile("Loltel", "default")) + .thenReturn(SimEntry(iccId = "iccId", eSimActivationCode = "eSimActivationCode", msisdnList = emptyList(), status = AVAILABLE_FOR_DOWNLOAD).right()) + + Mockito.`when`(mockSimManager.getSimProfile("Loltel", "iccId")) + .thenReturn(SimEntry(iccId = "iccId", eSimActivationCode = "eSimActivationCode", msisdnList = emptyList(), status = AVAILABLE_FOR_DOWNLOAD).right()) + + assert(Neo4jStoreSingleton.provisionSimProfile( + identity = IDENTITY, + regionCode = "no", + profileType = "default").isRight()) + + // test + Neo4jStoreSingleton.getRegionDetails(identity = IDENTITY, regionCode = "no") + .bimap( + { fail("Failed to fetch regions list") }, + { + assertEquals( + expected = RegionDetails( + region = Region("no", "Norway"), + status = APPROVED, + kycStatusMap = mapOf(JUMIO to KycStatus.PENDING), + simProfiles = listOf( + SimProfile( + iccId = "iccId", + eSimActivationCode = "eSimActivationCode", + status = AVAILABLE_FOR_DOWNLOAD))), + actual = it) + }) + } + + @Test + fun `test MY_INFO status`() { + + Neo4jStoreSingleton.createRegion(Region("sg", "Singapore")) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.createSegment(Segment(id = getSegmentNameFromCountryCode("sg"))) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + Neo4jStoreSingleton.getRegionDetails( + identity = IDENTITY, + regionCode = "sg") + .map { + fail("Should not have region details") + } + + Neo4jStoreSingleton.setKycStatus( + customerId = CUSTOMER.id, + regionCode = "sg", + kycType = MY_INFO) + .mapLeft { fail(it.message) } + + Neo4jStoreSingleton.getRegionDetails( + identity = IDENTITY, + regionCode = "sg") + .fold({ + fail("Failed to get Region Details - ${it.message}") + }, { + assertEquals(APPROVED, it.status) + }) + } + + @Test + fun `test NRIC_FIN JUMIO and ADDRESS_PHONE status`() { + + Neo4jStoreSingleton.createSegment(Segment(id = getSegmentNameFromCountryCode("sg"))) + + Neo4jStoreSingleton.createRegion(Region("sg", "Singapore")) + .mapLeft { fail(it.message) } + + assert(Neo4jStoreSingleton.addCustomer( + identity = IDENTITY, + customer = CUSTOMER).isRight()) + + Neo4jStoreSingleton.getRegionDetails( + identity = IDENTITY, + regionCode = "sg") + .map { + fail("Should not have region details") + } + + Neo4jStoreSingleton.setKycStatus( + customerId = CUSTOMER.id, + regionCode = "sg", + kycType = NRIC_FIN) + + Neo4jStoreSingleton.getRegionDetails( + identity = IDENTITY, + regionCode = "sg") + .fold({ + fail("Failed to get Region Details") + }, { + assertEquals(PENDING, it.status) + }) + + Neo4jStoreSingleton.setKycStatus( + customerId = CUSTOMER.id, + regionCode = "sg", + kycType = JUMIO) + + Neo4jStoreSingleton.getRegionDetails( + identity = IDENTITY, + regionCode = "sg") + .fold({ + fail("Failed to get Region Details") + }, { + assertEquals(PENDING, it.status) + }) + + Neo4jStoreSingleton.setKycStatus( + customerId = CUSTOMER.id, + regionCode = "sg", + kycType = ADDRESS_AND_PHONE_NUMBER) + + Neo4jStoreSingleton.getRegionDetails( + identity = IDENTITY, + regionCode = "sg") + .fold({ + fail("Failed to get Region Details") + }, { + assertEquals(APPROVED, it.status) + }) + } + + companion object { + const val EMAIL = "foo@bar.com" + const val NAME = "Test User" + const val CURRENCY = "NOK" + const val REGION = "NO" + const val MSISDN = "4712345678" + val IDENTITY = Identity(id = EMAIL, type = "EMAIL", provider = "email") + val CUSTOMER = Customer(contactEmail = EMAIL, nickname = NAME) + + @ClassRule + @JvmField + var docker: DockerComposeRule = DockerComposeRule.builder() + .file("src/test/resources/docker-compose.yaml") + .waitingForService("neo4j", HealthChecks.toHaveAllPortsOpen()) + .waitingForService("neo4j", + HealthChecks.toRespond2xxOverHttp(7474) { port -> + port.inFormat("http://\$HOST:\$EXTERNAL_PORT/browser") + }, + Duration.standardSeconds(40L)) + .build() + + @BeforeClass + @JvmStatic + fun start() { + ConfigRegistry.config = Config( + host = "0.0.0.0", + protocol = "bolt") + Neo4jClient.start() + } + + @AfterClass + @JvmStatic + fun stop() { + Neo4jClient.stop() + } + } +} \ No newline at end of file diff --git a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/ObjectHandlerTest.kt b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/ObjectHandlerTest.kt index 8c4224edd..bbdf55ceb 100644 --- a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/ObjectHandlerTest.kt +++ b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/ObjectHandlerTest.kt @@ -15,7 +15,7 @@ class ObjectHandlerTest { expectedMap["sku"] = "1GB_249NOK" expectedMap["price${separator}amount"] = 24900 expectedMap["price${separator}currency"] = "NOK" - expectedMap["properties${separator}noOfBytes"] = "1_000_000_000" + expectedMap["properties${separator}noOfBytes"] = "1_073_741_824" expectedMap["presentation${separator}label"] = "1 GB for 249" assertEquals(expectedMap, map) @@ -28,7 +28,7 @@ class ObjectHandlerTest { priceMap["currency"] = "NOK" val propertiesMap = LinkedHashMap() expectedNestedMap["properties"] = propertiesMap - propertiesMap["noOfBytes"] = "1_000_000_000" + propertiesMap["noOfBytes"] = "1_073_741_824" val presentationMap = LinkedHashMap() expectedNestedMap["presentation"] = presentationMap presentationMap["label"] = "1 GB for 249" diff --git a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/SchemaTest.kt b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/SchemaTest.kt index 8ebf67f10..eaf7183ad 100644 --- a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/SchemaTest.kt +++ b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/SchemaTest.kt @@ -1,7 +1,6 @@ package org.ostelco.prime.storage.graph import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.palantir.docker.compose.DockerComposeRule import com.palantir.docker.compose.connection.waiting.HealthChecks import org.joda.time.Duration @@ -10,7 +9,9 @@ import org.junit.BeforeClass import org.junit.ClassRule import org.junit.Test import org.neo4j.driver.v1.AccessMode.WRITE +import org.ostelco.prime.jsonmapper.objectMapper import org.ostelco.prime.model.HasId +import org.ostelco.prime.storage.AlreadyExistsError import org.ostelco.prime.storage.graph.Relation.REFERRED import kotlin.test.BeforeTest import kotlin.test.assertEquals @@ -21,9 +22,9 @@ class SchemaTest { @BeforeTest fun clear() { - Neo4jClient.driver.session(WRITE).use { - it.writeTransaction { - it.run("MATCH (n) DETACH DELETE n") + Neo4jClient.driver.session(WRITE).use { session -> + session.writeTransaction { transaction -> + transaction.run("MATCH (n) DETACH DELETE n") } } } @@ -186,11 +187,54 @@ class SchemaTest { @Test fun `json to map`() { - val objectMapper = jacksonObjectMapper() val map = objectMapper.readValue>("""{"label":"3GB for 300 NOK"}""", object : TypeReference>() {}) assertEquals("3GB for 300 NOK", map["label"]) } + + @Test + fun `test unique relation store`() { + + writeTransaction { + val aId = "a_id" + val bId = "b_id" + + val fromEntity = EntityType(A::class.java) + val fromEntityStore = EntityStore(fromEntity) + + val toEntity = EntityType(B::class.java) + val toEntityStore = EntityStore(toEntity) + + val relation = RelationType(REFERRED, fromEntity, toEntity, R::class.java) + val uniqueRelationStore = UniqueRelationStore(relation) + + // create nodes + val a = A() + a.id = aId + a.field1 = "a's value1" + a.field2 = "a's value2" + + val b = B() + b.id = bId + b.field1 = "b's value1" + b.field2 = "b's value2" + + fromEntityStore.create(a, transaction) + toEntityStore.create(b, transaction) + + // create relation + uniqueRelationStore.create(a.id, b.id, transaction) + .mapLeft { fail(it.message) } + uniqueRelationStore.createIfAbsent(a.id, b.id, transaction) + .mapLeft { fail(it.message) } + uniqueRelationStore.createIfAbsent(a.id, b.id, transaction) + .mapLeft { fail(it.message) } + uniqueRelationStore.create(a.id, b.id, transaction).fold( + { storeError -> assert(storeError is AlreadyExistsError) }, + { fail("Created duplicate relation") }) + } + } + companion object { @ClassRule @JvmField @@ -207,9 +251,9 @@ class SchemaTest { @BeforeClass @JvmStatic fun start() { - ConfigRegistry.config = Config() - ConfigRegistry.config.host = "0.0.0.0" - ConfigRegistry.config.protocol = "bolt" + ConfigRegistry.config = Config( + host = "0.0.0.0", + protocol = "bolt") Neo4jClient.start() } diff --git a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.analytics.AnalyticsService b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.analytics.AnalyticsService new file mode 100644 index 000000000..1a739ef6e --- /dev/null +++ b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.analytics.AnalyticsService @@ -0,0 +1 @@ +org.ostelco.prime.storage.graph.MockAnalyticsService \ No newline at end of file diff --git a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.appnotifier.AppNotifier b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.appnotifier.AppNotifier new file mode 100644 index 000000000..419988531 --- /dev/null +++ b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.appnotifier.AppNotifier @@ -0,0 +1 @@ +org.ostelco.prime.storage.graph.MockAppNotifier \ No newline at end of file diff --git a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.notifications.EmailNotifier b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.notifications.EmailNotifier new file mode 100644 index 000000000..1085fb349 --- /dev/null +++ b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.notifications.EmailNotifier @@ -0,0 +1 @@ +org.ostelco.prime.storage.graph.MockEmailNotifier \ No newline at end of file diff --git a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.ocs.OcsAdminService b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.ocs.OcsAdminService deleted file mode 100644 index b3b33a72f..000000000 --- a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.ocs.OcsAdminService +++ /dev/null @@ -1 +0,0 @@ -org.ostelco.prime.storage.graph.MockOcsAdminService \ No newline at end of file diff --git a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.paymentprocessor.PaymentProcessor b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.paymentprocessor.PaymentProcessor new file mode 100644 index 000000000..c919e0135 --- /dev/null +++ b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.paymentprocessor.PaymentProcessor @@ -0,0 +1 @@ +org.ostelco.prime.storage.graph.MockPaymentProcessor \ No newline at end of file diff --git a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.sim.SimManager b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.sim.SimManager new file mode 100644 index 000000000..2e2d95503 --- /dev/null +++ b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.sim.SimManager @@ -0,0 +1 @@ +org.ostelco.prime.storage.graph.MockSimManager \ No newline at end of file diff --git a/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.storage.ScanInformationStore b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.storage.ScanInformationStore new file mode 100644 index 000000000..80246b750 --- /dev/null +++ b/neo4j-store/src/test/resources/META-INF/services/org.ostelco.prime.storage.ScanInformationStore @@ -0,0 +1 @@ +org.ostelco.prime.storage.graph.MockScanInformationStore \ No newline at end of file diff --git a/neo4j-store/src/test/resources/docker-compose.yaml b/neo4j-store/src/test/resources/docker-compose.yaml index 76e311981..ed672849e 100644 --- a/neo4j-store/src/test/resources/docker-compose.yaml +++ b/neo4j-store/src/test/resources/docker-compose.yaml @@ -3,7 +3,7 @@ version: "3.3" services: neo4j: container_name: "neo4j" - image: neo4j:3.4.8 + image: neo4j:3.4.9 environment: - NEO4J_AUTH=none ports: diff --git a/ocs-grpc-api/build.gradle b/ocs-grpc-api/build.gradle index fb2ba62f8..05cc219b6 100644 --- a/ocs-grpc-api/build.gradle +++ b/ocs-grpc-api/build.gradle @@ -1,6 +1,6 @@ plugins { id "java-library" - id "com.google.protobuf" version "0.8.6" + id "com.google.protobuf" version "0.8.8" id "idea" } @@ -18,7 +18,7 @@ protobuf { artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" } } - protoc { artifact = 'com.google.protobuf:protoc:3.6.1' } + protoc { artifact = "com.google.protobuf:protoc:$protocVersion" } generateProtoTasks { all()*.plugins { grpc {} diff --git a/ocs-grpc-api/src/main/proto/ocs.proto b/ocs-grpc-api/src/main/proto/ocs.proto index f4afe8537..99ab678c0 100644 --- a/ocs-grpc-api/src/main/proto/ocs.proto +++ b/ocs-grpc-api/src/main/proto/ocs.proto @@ -18,7 +18,7 @@ message UserEquipmentInfo { message ServiceUnit { uint64 totalOctets = 1; uint64 inputOctets = 2; - uint64 outputOctetes = 3; + uint64 outputOctets = 3; ReportingReason reportingReason = 4; } @@ -56,6 +56,20 @@ enum ReportingReason { UNUSED_QUOTA_TIMER = 9; } +enum ResultCode { + UNKNOWN = 0; + DIAMETER_SUCCESS = 2001; + DIAMETER_LIMITED_SUCCESS = 2002; + DIAMETER_END_USER_SERVICE_DENIED = 4010; + DIAMETER_CREDIT_CONTROL_NOT_APPLICABLE = 4011; + DIAMETER_CREDIT_LIMIT_REACHED = 4012; + DIAMETER_INVALID_AVP_VALUE = 5004; + DIAMETER_MISSING_AVP = 5005; + DIAMETER_UNABLE_TO_COMPLY = 5012; + DIAMETER_USER_UNKNOWN = 5030; + DIAMETER_RATING_FAILED = 5031; +} + message RedirectServer { RedirectAddressType redirectAddressType = 1; string redirectServerAddress = 2; @@ -70,14 +84,17 @@ message FinalUnitIndication { } message MultipleServiceCreditControl { - uint64 serviceIdentifier = 1; - uint64 ratingGroup = 2; - ServiceUnit requested = 3; - ServiceUnit used = 4; - ServiceUnit granted = 5; - FinalUnitIndication finalUnitIndication = 6; - uint32 validityTime = 7; - ReportingReason reportingReason = 8; + uint64 serviceIdentifier = 1; + uint64 ratingGroup = 2; + ServiceUnit requested = 3; + ServiceUnit used = 4; + ServiceUnit granted = 5; + FinalUnitIndication finalUnitIndication = 6; + uint32 validityTime = 7; + ReportingReason reportingReason = 8; + ResultCode resultCode = 9; + uint64 volumeQuotaThreshold = 10; + uint64 quotaHoldingTime = 11; } message PsInformation { @@ -96,12 +113,14 @@ message CreditControlRequestInfo { string imsi = 4; repeated MultipleServiceCreditControl mscc = 5; ServiceInfo serviceInformation = 6; + string topicId = 7; } message CreditControlAnswerInfo { string requestId = 1; string msisdn = 2; repeated MultipleServiceCreditControl mscc = 3; + ResultCode resultCode = 4; } diff --git a/ocs-ktc/build.gradle b/ocs-ktc/build.gradle new file mode 100644 index 000000000..7675f1563 --- /dev/null +++ b/ocs-ktc/build.gradle @@ -0,0 +1,28 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "java-library" +} + +repositories { + maven { + url 'https://dl.bintray.com/palantir/releases' // docker-compose-rule is published on bintray + } +} + +dependencies { + implementation project(':prime-modules') + + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinXCoroutinesVersion" + + implementation "com.google.cloud:google-cloud-pubsub:$googleCloudVersion" + + testImplementation "com.palantir.docker.compose:docker-compose-rule-junit4:$dockerComposeJunitRuleVersion" + + testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" + + testImplementation "org.mockito:mockito-core:$mockitoVersion" + +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/OcsModule.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/OcsModule.kt new file mode 100644 index 000000000..8b90b53db --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/OcsModule.kt @@ -0,0 +1,49 @@ +package org.ostelco.prime.ocs + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonTypeName +import io.dropwizard.setup.Environment +import org.ostelco.prime.module.PrimeModule +import org.ostelco.prime.ocs.ConfigRegistry.config +import org.ostelco.prime.ocs.consumption.grpc.OcsGrpcServer +import org.ostelco.prime.ocs.consumption.grpc.OcsGrpcService +import org.ostelco.prime.ocs.consumption.pubsub.PubSubClient +import org.ostelco.prime.ocs.core.OnlineCharging + +@JsonTypeName("ocs") +class OcsModule : PrimeModule { + + @JsonProperty + fun setConfig(config: Config) { + ConfigRegistry.config = config + } + + override fun init(env: Environment) { + env.lifecycle().manage( + OcsGrpcServer( + port = 8082, + service = OcsGrpcService(OnlineCharging))) + + config.pubSubChannel?.let { config -> + env.lifecycle().manage( + PubSubClient( + ocsAsyncRequestConsumer = OnlineCharging, + projectId = config.projectId, + activateTopicId = config.activateTopicId, + ccrSubscriptionId = config.ccrSubscriptionId)) + } + } +} + +data class PubSubChannel( + val projectId: String, + val activateTopicId: String, + val ccrSubscriptionId: String) + +data class Config( + val lowBalanceThreshold: Long = 0, + val pubSubChannel: PubSubChannel? = null) + +object ConfigRegistry { + lateinit var config: Config +} \ No newline at end of file diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/analytics/AnalyticsReporter.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/analytics/AnalyticsReporter.kt new file mode 100644 index 000000000..66cef53b7 --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/analytics/AnalyticsReporter.kt @@ -0,0 +1,26 @@ +package org.ostelco.prime.ocs.analytics + +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.prime.analytics.AnalyticsService +import org.ostelco.prime.getLogger +import org.ostelco.prime.module.getResource + +/** + * This class publishes the data consumption information events analytics. + */ +object AnalyticsReporter { + + private val logger by getLogger() + + private val analyticsReporter by lazy { getResource() } + + fun report(msisdnAnalyticsId: String, request: CreditControlRequestInfo, bundleBytes: Long) { + logger.info("Sent Data Consumption info event to analytics") + analyticsReporter.reportTrafficInfo( + msisdnAnalyticsId = msisdnAnalyticsId, + usedBytes = request.msccList?.firstOrNull()?.used?.totalOctets ?: 0L, + bundleBytes = bundleBytes, + apn = request.serviceInformation?.psInformation?.calledStationId, + mccMnc = request.serviceInformation?.psInformation?.sgsnMccMnc) + } +} diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/Interfaces.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/Interfaces.kt new file mode 100644 index 000000000..b7cf16c3b --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/Interfaces.kt @@ -0,0 +1,24 @@ +package org.ostelco.prime.ocs.consumption + +import org.ostelco.ocs.api.ActivateResponse +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo + +/** + * Ocs Requests from [OcsGrpcService] are consumed by implementation [OcsService] of [OcsAsyncRequestConsumer] + */ +interface OcsAsyncRequestConsumer { + fun creditControlRequestEvent( + request: CreditControlRequestInfo, + returnCreditControlAnswer: + (CreditControlAnswerInfo) -> Unit) +} + +/** + * Ocs Events from [OcsEventToGrpcResponseMapper] forwarded to implementation [OcsService] of [OcsAsyncResponseProducer] + */ +interface OcsAsyncResponseProducer { + fun activateOnNextResponse(response: ActivateResponse) + fun sendCreditControlAnswer(streamId: String, creditControlAnswer: CreditControlAnswerInfo) + fun returnUnusedDataBucketEvent(msisdn: String, reservedBucketBytes: Long) +} \ No newline at end of file diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcServer.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcServer.kt new file mode 100644 index 000000000..961ce9860 --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcServer.kt @@ -0,0 +1,43 @@ +package org.ostelco.prime.ocs.consumption.grpc + +import io.dropwizard.lifecycle.Managed +import io.grpc.BindableService +import io.grpc.Server +import io.grpc.ServerBuilder +import org.ostelco.prime.getLogger + +/** + * This is OCS Server running on gRPC protocol. + * Its startup and shutdown are managed by Dropwizard's lifecycle + * through the Managed interface. + * + */ +class OcsGrpcServer(private val port: Int, service: BindableService) : Managed { + + private val logger by getLogger() + + // may add Transport Security with Certificates if needed. + // may add executor for control over number of threads + private val server: Server = ServerBuilder.forPort(port).addService(service).build() + + override fun start() { + server.start() + logger.info("OcsServer Server started, listening for incoming gRPC traffic on {}", port) + } + + override fun stop() { + logger.info("Stopping OcsServer Server listening for gRPC traffic on {}", port) + server.shutdown() + blockUntilShutdown() + } + + // Used for unit testing + fun forceStop() { + logger.info("Stopping forcefully OcsServer Server listening for gRPC traffic on {}", port) + server.shutdownNow() + } + + private fun blockUntilShutdown() { + server.awaitTermination() + } +} diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcService.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcService.kt new file mode 100644 index 000000000..8fcf47470 --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcService.kt @@ -0,0 +1,112 @@ +package org.ostelco.prime.ocs.consumption.grpc + +import io.grpc.stub.StreamObserver +import org.ostelco.ocs.api.ActivateRequest +import org.ostelco.ocs.api.ActivateResponse +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.ocs.api.CreditControlRequestType.NONE +import org.ostelco.ocs.api.OcsServiceGrpc +import org.ostelco.prime.getLogger +import org.ostelco.prime.ocs.consumption.OcsAsyncRequestConsumer +import java.util.* + +/** + * A service that can be used to serve incoming GRPC requests. + * is typically bound to a service port using the GRPC ServerBuilder mechanism + * provide by GRPC: + * + * ` + * server = ServerBuilder. + * forPort(port). + * addService(service). + * build(); +` * + * + * It's implemented as a subclass of [OcsServiceGrpc.OcsServiceImplBase] overriding + * methods that together implements the protocol described in the ocs.proto file: + * + * ` + * // OCS Service + * service OcsService { + * rpc CreditControlRequest (stream CreditControlRequestInfo) returns (stream CreditControlAnswerInfo) {} + * rpc Activate (ActivateRequest) returns (stream ActivateResponse) {} + * } +` * + * + * The "stream" type parameters represents sequences of responses, so typically we will here + * see that a client invokes a method, and listens for a stream of information related to + * that particular stream. + */ +class OcsGrpcService(private val ocsAsyncRequestConsumer: OcsAsyncRequestConsumer) : OcsServiceGrpc.OcsServiceImplBase() { + + private val logger by getLogger() + + /** + * Method to handle Credit-Control-Requests + * + * @param creditControlAnswer Stream used to send Credit-Control-Answer back to requester + */ + override fun creditControlRequest(creditControlAnswer: StreamObserver): StreamObserver { + + val streamId = newUniqueStreamId() + logger.info("Starting Credit-Control-Request with streamId: {}", streamId) + return object : StreamObserver { + + /** + * This method gets called every time a Credit-Control-Request is received + * from the OCS. + * @param request + */ + override fun onNext(request: CreditControlRequestInfo) { + + if (request.type == NONE) { + // this request is just to keep connection alive + return + } + logger.info("Received Credit-Control-Request request :: " + "for MSISDN: {} with request id: {}", + request.msisdn, request.requestId) + + ocsAsyncRequestConsumer.creditControlRequestEvent( + request = request, + returnCreditControlAnswer = creditControlAnswer::onNext) + } + + override fun onError(t: Throwable) { + // TODO vihang: handle onError for stream observers + } + + override fun onCompleted() { + logger.info("Credit-Control-Request with streamId: {} completed", streamId) + creditControlAnswer.onCompleted() + } + } + } + + /** + * The `ActivateRequest` does not have any fields, and so it is ignored. + * In return, the server starts to send "stream" of `ActivateResponse` + * which is actually a "request". + * + * After the connection, the first response will have empty string as MSISDN. + * It should to be ignored by OCS gateway. This method sends that empty + * response back to the invoker. + * + * @param request Is ignored. + * @param activateResponse the stream observer used to send the response back. + */ + override fun activate( + request: ActivateRequest, + activateResponse: StreamObserver) { + + val streamId = newUniqueStreamId() + logger.info("Starting Activate-Response stream with streamId: {}", streamId) + + val initialDummyResponse = ActivateResponse.newBuilder().setMsisdn("").build() + activateResponse.onNext(initialDummyResponse) + } + + private fun newUniqueStreamId(): String { + return UUID.randomUUID().toString() + } +} diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/pubsub/PubSubClient.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/pubsub/PubSubClient.kt new file mode 100644 index 000000000..855d7130c --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/consumption/pubsub/PubSubClient.kt @@ -0,0 +1,158 @@ +package org.ostelco.prime.ocs.consumption.pubsub + +import com.google.api.core.ApiFutureCallback +import com.google.api.core.ApiFutures +import com.google.api.gax.core.NoCredentialsProvider +import com.google.api.gax.grpc.GrpcTransportChannel +import com.google.api.gax.rpc.ApiException +import com.google.api.gax.rpc.FixedTransportChannelProvider +import com.google.api.gax.rpc.TransportChannelProvider +import com.google.cloud.pubsub.v1.AckReplyConsumer +import com.google.cloud.pubsub.v1.MessageReceiver +import com.google.cloud.pubsub.v1.Publisher +import com.google.cloud.pubsub.v1.Subscriber +import com.google.protobuf.ByteString +import com.google.pubsub.v1.ProjectSubscriptionName +import com.google.pubsub.v1.ProjectTopicName +import com.google.pubsub.v1.PubsubMessage +import io.dropwizard.lifecycle.Managed +import io.grpc.ManagedChannelBuilder +import org.ostelco.ocs.api.ActivateResponse +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.prime.getLogger +import org.ostelco.prime.ocs.consumption.OcsAsyncRequestConsumer +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService + +class PubSubClient( + private val ocsAsyncRequestConsumer: OcsAsyncRequestConsumer, + private val projectId: String, + private val activateTopicId: String, + private val ccrSubscriptionId: String) : Managed { + + private val logger by getLogger() + + private var singleThreadScheduledExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + + private var pubSubChannelProvider: TransportChannelProvider? = null + + private var activatePublisher: Publisher? = null + private var ccrPublisherMaps: ConcurrentHashMap = ConcurrentHashMap() + + override fun start() { + + val strSocketAddress = System.getenv("PUBSUB_EMULATOR_HOST") ?: System.getProperty("PUBSUB_EMULATOR_HOST") + if (!strSocketAddress.isNullOrBlank()) { + val channel = ManagedChannelBuilder.forTarget(strSocketAddress).usePlaintext().build() + // Create a publisher instance with default settings bound to the topic + pubSubChannelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)) + } + + // init publishers + activatePublisher = setupPublisherToTopic(activateTopicId) + + // init subscriber + setupPubSubSubscriber(subscriptionId = ccrSubscriptionId) { message, consumer -> + val ccrInfo = CreditControlRequestInfo.parseFrom(message) + logger.info("Received CCR with request-id: {}", ccrInfo.requestId) + ocsAsyncRequestConsumer.creditControlRequestEvent(ccrInfo) { + logger.info("Sending CCA with request-id: {}", ccrInfo.requestId) + publish(messageId = ccrInfo.requestId, + byteString = it.toByteString(), + publisher = ccrPublisherMaps.getOrPut(ccrInfo.topicId) { + setupPublisherToTopic(ccrInfo.topicId) + }) + } + consumer.ack() + } + } + + override fun stop() = activatePublisher?.shutdown() ?: Unit + + fun activate(msisdn: String) { + val activateResponse = ActivateResponse.newBuilder() + .setMsisdn(msisdn) + .build() + + activatePublisher?.apply { + publish(messageId = UUID.randomUUID().toString(), + byteString = activateResponse.toByteString(), + publisher = this) + } + } + + internal fun publish(messageId: String, byteString: ByteString, publisher: Publisher) { + + val base64String = Base64.getEncoder().encodeToString(byteString.toByteArray()) + logger.debug("[>>] base64String: {}", base64String) + val pubsubMessage = PubsubMessage.newBuilder() + .setMessageId(messageId) + .setData(ByteString.copyFromUtf8(base64String)) + .build() + + val future = publisher.publish(pubsubMessage) + + ApiFutures.addCallback(future, object : ApiFutureCallback { + + override fun onFailure(throwable: Throwable) { + if (throwable is ApiException) { + // details on the API exception + logger.error("Status code: {}", throwable.statusCode.code) + logger.error("Retrying: {}", throwable.isRetryable) + } + logger.error("Error sending CCR Request to PubSub") + } + + override fun onSuccess(messageId: String) { + // Once published, returns server-assigned message ids (unique within the topic) + logger.debug("Submitted message with request-id: {} successfully", messageId) + } + }, singleThreadScheduledExecutor) + } + + internal fun setupPubSubSubscriber(subscriptionId: String, handler: (ByteString, AckReplyConsumer) -> Unit) { + // init subscriber + logger.info("Setting up Subscriber for subscription: {}", subscriptionId) + val subscriptionName = ProjectSubscriptionName.of(projectId, subscriptionId) + + val receiver = MessageReceiver { message, consumer -> + val base64String = message.data.toStringUtf8() + logger.debug("[<<] base64String: {}", base64String) + handler(ByteString.copyFrom(Base64.getDecoder().decode(base64String)), consumer) + } + + val subscriber: Subscriber? + try { + // Create a subscriber for "my-subscription-id" bound to the message receiver + subscriber = pubSubChannelProvider + ?.let {channelProvider -> + Subscriber.newBuilder(subscriptionName, receiver) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider()) + .build() + } + ?: Subscriber.newBuilder(subscriptionName, receiver) + .build() + subscriber?.startAsync()?.awaitRunning() + } finally { + // TODO vihang: Stop this in Managed.stop() + // stop receiving messages + // subscriber?.stopAsync() + } + } + + internal fun setupPublisherToTopic(topicId: String): Publisher { + logger.info("Setting up Publisher for topic: {}", topicId) + val topicName = ProjectTopicName.of(projectId, topicId) + return pubSubChannelProvider + ?.let { channelProvider -> + Publisher.newBuilder(topicName) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider()) + .build() + } + ?: Publisher.newBuilder(topicName).build() + } +} \ No newline at end of file diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/core/OnlineCharging.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/core/OnlineCharging.kt new file mode 100644 index 000000000..e99f97457 --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/core/OnlineCharging.kt @@ -0,0 +1,151 @@ +package org.ostelco.prime.ocs.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.ocs.api.FinalUnitAction +import org.ostelco.ocs.api.FinalUnitIndication +import org.ostelco.ocs.api.MultipleServiceCreditControl +import org.ostelco.ocs.api.ReportingReason +import org.ostelco.ocs.api.ResultCode +import org.ostelco.ocs.api.ServiceUnit +import org.ostelco.prime.module.getResource +import org.ostelco.prime.ocs.analytics.AnalyticsReporter +import org.ostelco.prime.ocs.consumption.OcsAsyncRequestConsumer +import org.ostelco.prime.storage.ClientDataSource +import org.ostelco.prime.storage.ConsumptionResult +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + + +object OnlineCharging : OcsAsyncRequestConsumer { + + var loadUnitTest = false + private val loadAcceptanceTest = System.getenv("LOAD_TESTING") == "true" + + private val storage: ClientDataSource = getResource() + + override fun creditControlRequestEvent( + request: CreditControlRequestInfo, + returnCreditControlAnswer: (CreditControlAnswerInfo) -> Unit) { + + val msisdn = request.msisdn + + if (msisdn != null) { + CoroutineScope(Dispatchers.Default).launch { + + val response = CreditControlAnswerInfo.newBuilder() + .setRequestId(request.requestId) + .setMsisdn(msisdn) + .setResultCode(ResultCode.DIAMETER_SUCCESS) + + val doneSignal = CountDownLatch(request.msccList.size) + + request.msccList.forEach { mscc -> + + val requested = mscc.requested?.totalOctets ?: 0 + val used = mscc.used?.totalOctets ?: 0 + if (shouldConsume(mscc.ratingGroup, mscc.serviceIdentifier)) { + storage.consume(msisdn, used, requested) { storeResult -> + storeResult.fold( + { + response.resultCode = ResultCode.DIAMETER_USER_UNKNOWN + doneSignal.countDown() + }, + { + consumptionResult -> addGrantedQuota(consumptionResult.granted, mscc, response) + reportAnalytics(consumptionResult, request) + doneSignal.countDown() + } + ) + } + } else { // zeroRate + + addGrantedQuota(requested, mscc, response) + doneSignal.countDown() + } + } + doneSignal.await(2, TimeUnit.SECONDS) + synchronized(OnlineCharging) { + returnCreditControlAnswer(response.build()) + } + } + } + } + + private fun reportAnalytics(consumptionResult : ConsumptionResult, request: CreditControlRequestInfo) { + if (!loadUnitTest && !loadAcceptanceTest) { + CoroutineScope(Dispatchers.Default).launch { + AnalyticsReporter.report( + msisdnAnalyticsId = consumptionResult.msisdnAnalyticsId, + request = request, + bundleBytes = consumptionResult.balance) + } + + // FIXME vihang: get customerId for MSISDN + /*launch { + Notifications.lowBalanceAlert( + customerId = msisdn, + reserved = consumptionResult.granted, + balance = consumptionResult.balance) + }*/ + } + } + + private fun addGrantedQuota(granted: Long, mscc: MultipleServiceCreditControl, response: CreditControlAnswerInfo.Builder) { + + val responseMscc = MultipleServiceCreditControl + .newBuilder(mscc) + .setValidityTime(86400) + + val grantedTotalOctets = if (mscc.reportingReason != ReportingReason.FINAL && mscc.requested.totalOctets > 0) { + + granted + } else { + // Use -1 to indicate no granted service unit should be included in the answer + -1 + } + + responseMscc.granted = ServiceUnit.newBuilder().setTotalOctets(grantedTotalOctets).build() + + if (grantedTotalOctets > 0) { + + responseMscc.quotaHoldingTime = 7200 + + if (granted < mscc.requested.totalOctets) { + responseMscc.finalUnitIndication = FinalUnitIndication.newBuilder() + .setFinalUnitAction(FinalUnitAction.TERMINATE) + .setIsSet(true) + .build() + + responseMscc.volumeQuotaThreshold = 0L + } else { + responseMscc.volumeQuotaThreshold = (grantedTotalOctets * 0.2).toLong() // When client has 20% left + } + } + + responseMscc.resultCode = ResultCode.DIAMETER_SUCCESS + + synchronized(OnlineCharging) { + response.addMscc(responseMscc.build()) + } + } + + private fun shouldConsume(ratingGroup: Long, serviceIdentifier: Long): Boolean { + + // FixMe : Fetch list from somewhere ™ + // For now hardcoded to known combinations + + if (arrayOf(600L).contains(ratingGroup)) { + return true + } + + if (arrayOf(1L, 400L).contains(serviceIdentifier)) { + return true + } + + return false + } +} \ No newline at end of file diff --git a/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/notifications/Notifications.kt b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/notifications/Notifications.kt new file mode 100644 index 000000000..04c1f6497 --- /dev/null +++ b/ocs-ktc/src/main/kotlin/org/ostelco/prime/ocs/notifications/Notifications.kt @@ -0,0 +1,17 @@ +package org.ostelco.prime.ocs.notifications + +import org.ostelco.prime.appnotifier.AppNotifier +import org.ostelco.prime.module.getResource +import org.ostelco.prime.ocs.ConfigRegistry + +object Notifications { + + private val appNotifier by lazy { getResource() } + + fun lowBalanceAlert(customerId: String, reserved: Long, balance: Long) { + val lowBalanceThreshold = ConfigRegistry.config.lowBalanceThreshold + if ((balance < lowBalanceThreshold) && ((balance + reserved) > lowBalanceThreshold)) { + appNotifier.notify(customerId, "Pi", "You have less then " + lowBalanceThreshold / 1000000 + "Mb data left") + } + } +} \ No newline at end of file diff --git a/ocs-ktc/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable b/ocs-ktc/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable new file mode 100644 index 000000000..8056fe23b --- /dev/null +++ b/ocs-ktc/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable @@ -0,0 +1 @@ +org.ostelco.prime.module.PrimeModule \ No newline at end of file diff --git a/ocs-ktc/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/ocs-ktc/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule new file mode 100644 index 000000000..f639024a8 --- /dev/null +++ b/ocs-ktc/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule @@ -0,0 +1 @@ +org.ostelco.prime.ocs.OcsModule \ No newline at end of file diff --git a/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/Setup.kt b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/Setup.kt new file mode 100644 index 000000000..da901e1fa --- /dev/null +++ b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/Setup.kt @@ -0,0 +1,21 @@ +package org.ostelco.prime.ocs + +import arrow.core.Either +import arrow.core.right +import org.mockito.Mockito +import org.ostelco.prime.storage.ConsumptionResult +import org.ostelco.prime.storage.DocumentStore +import org.ostelco.prime.storage.GraphStore +import org.ostelco.prime.storage.StoreError + +private val mockDocumentStore = Mockito.mock(DocumentStore::class.java) + +class MockDocumentStore : DocumentStore by mockDocumentStore + +val mockGraphStore: GraphStore = Mockito.mock(GraphStore::class.java) + +class MockGraphStore : GraphStore by mockGraphStore { + override suspend fun consume(msisdn: String, usedBytes: Long, requestedBytes: Long, callback: (Either) -> Unit) { + callback(ConsumptionResult(msisdnAnalyticsId = "", granted = 100L, balance = 200L).right()) + } +} diff --git a/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcServerTest.kt b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcServerTest.kt new file mode 100644 index 000000000..bcf33a51b --- /dev/null +++ b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/consumption/grpc/OcsGrpcServerTest.kt @@ -0,0 +1,111 @@ +package org.ostelco.prime.ocs.consumption.grpc + +import io.grpc.ManagedChannelBuilder +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.runBlocking +import org.junit.Assert.fail +import org.junit.Ignore +import org.junit.Test +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.ocs.api.CreditControlRequestType.UPDATE_REQUEST +import org.ostelco.ocs.api.MultipleServiceCreditControl +import org.ostelco.ocs.api.OcsServiceGrpc +import org.ostelco.ocs.api.ServiceUnit +import org.ostelco.prime.ocs.core.OnlineCharging +import java.time.Instant +import java.util.* +import java.util.concurrent.CountDownLatch +import kotlin.test.AfterTest + +class OcsGrpcServerTest { + + private lateinit var server: OcsGrpcServer + + @Ignore + @Test + fun `load test OCS using gRPC`() = runBlocking { + + // Add delay to DB call and skip analytics and low balance notification + OnlineCharging.loadUnitTest = true + + server = OcsGrpcServer(8082, OcsGrpcService(OnlineCharging)) + + server.start() + + // Setup gRPC client + val channel = ManagedChannelBuilder + .forTarget("localhost:8082") + .usePlaintext() + .build() + + val ocsService = OcsServiceGrpc.newStub(channel) + + // count down latch to wait for all responses to return + val cdl = CountDownLatch(COUNT) + + // response handle which will count down on receiving response + val requestStream = ocsService.creditControlRequest(object : StreamObserver { + + override fun onNext(value: CreditControlAnswerInfo?) { + // count down on receiving response + cdl.countDown() + } + + override fun onError(t: Throwable?) { + fail(t?.message) + } + + override fun onCompleted() { + + } + }) + + // Sample request which will be sent repeatedly + val request = CreditControlRequestInfo.newBuilder() + .setRequestId(UUID.randomUUID().toString()) + .setType(UPDATE_REQUEST) + .setMsisdn(MSISDN) + .addMscc(0, MultipleServiceCreditControl.newBuilder() + .setRequested(ServiceUnit.newBuilder().setTotalOctets(100)) + .setUsed(ServiceUnit.newBuilder().setTotalOctets(80))) + .build() + + // Start timestamp in millisecond + val start = Instant.now() + + // Send the same request COUNT times + repeat(COUNT) { + requestStream.onNext(request) + } + + // Wait for all the responses to be returned + println("Waiting for all responses to be returned") + cdl.await() + + // Stop timestamp in millisecond + val stop = Instant.now() + + requestStream.onCompleted() + + // Print load test results + val diff = stop.toEpochMilli() - start.toEpochMilli() + println("Time diff: %,d milli sec".format(diff)) + val rate = COUNT * 1000.0 / diff + println("Rate: %,.2f req/sec".format(rate)) + + server.forceStop() + } + + @AfterTest + fun cleanup() { + if (::server.isInitialized) { + server.forceStop() + } + } + + companion object { + private const val COUNT = 100_000 + private const val MSISDN = "4790300147" + } +} \ No newline at end of file diff --git a/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/consumption/pubsub/OcsPubSubTest.kt b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/consumption/pubsub/OcsPubSubTest.kt new file mode 100644 index 000000000..3ab9a3d1a --- /dev/null +++ b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/consumption/pubsub/OcsPubSubTest.kt @@ -0,0 +1,136 @@ +package org.ostelco.prime.ocs.consumption.pubsub + +import com.google.cloud.pubsub.v1.Publisher +import com.palantir.docker.compose.DockerComposeRule +import com.palantir.docker.compose.connection.waiting.HealthChecks +import org.joda.time.Duration +import org.junit.After +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.prime.getLogger +import org.ostelco.prime.jersey.client.put +import org.ostelco.prime.ocs.core.OnlineCharging +import java.util.* +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.SECONDS + +private const val PROJECT_ID = "dummyGcpProjectId" +private const val TARGET = "http://0.0.0.0:8085" +private const val CCR_TOPIC = "ocs-ccr" +private const val CCA_TOPIC = "ocs-cca" +private const val ACTIVATE_TOPIC = "ocs-activate" +private const val CCR_SUBSCRIPTION = "ocs-ccr-sub" +private const val CCA_SUBSCRIPTION = "ocsgw-cca-sub" + +class OcsPubSubTest { + + private val logger by getLogger() + + private lateinit var pubSubClient: PubSubClient + private lateinit var publisher: Publisher + + @Before + fun `setup Topics and Subscriptions`() { + + createTopicWithSubscription(CCR_TOPIC, CCR_SUBSCRIPTION) + createTopicWithSubscription(CCA_TOPIC, CCA_SUBSCRIPTION) + createTopicWithSubscription(ACTIVATE_TOPIC, "ocsgw-activate-sub") + + System.setProperty("PUBSUB_EMULATOR_HOST", "0.0.0.0:8085") + + pubSubClient = PubSubClient( + ocsAsyncRequestConsumer = OnlineCharging, + projectId = PROJECT_ID, + activateTopicId = ACTIVATE_TOPIC, + ccrSubscriptionId = CCR_SUBSCRIPTION) + + pubSubClient.start() + } + + @After + fun stop() { + if (::pubSubClient.isInitialized) { + pubSubClient.stop() + } + if (::publisher.isInitialized) { + publisher.shutdown() + } + } + + @Test + fun `test OCS over PubSub`() { + + val cdl = CountDownLatch(1) + pubSubClient.setupPubSubSubscriber(CCA_SUBSCRIPTION) { message, consumer -> + val ccaInfo = CreditControlAnswerInfo.parseFrom(message) + logger.info("Received CCA - {}", ccaInfo) + cdl.countDown() + consumer.ack() + } + + publisher = pubSubClient.setupPublisherToTopic(CCR_TOPIC) + + val requestId = UUID.randomUUID().toString() + val ccrInfo = CreditControlRequestInfo.newBuilder() + .setRequestId(requestId) + .setMsisdn("471234568") + .setTopicId(CCA_TOPIC) + .build() + + logger.info("Sending CCR - {}", ccrInfo) + pubSubClient.publish( + messageId = requestId, + byteString = ccrInfo.toByteString(), + publisher = publisher) + + logger.info("Waiting for CCA") + assert(cdl.await(15, SECONDS)) { "Failed to received CCA back on PubSub" } + } + + private fun createTopicWithSubscription(topicId: String, vararg subscriptionIds: String) { + logger.info("Created topic: {}", createTopic(topicId).name) + subscriptionIds.forEach { subscriptionId -> + createSubscription(topicId, subscriptionId).apply { + logger.info("Created subscription: {} for topic: {}", this.name, this.topic) + } + } + } + + private fun createTopic(topicId: String): CreateTopicResponse = put { + target = TARGET + path = "v1/projects/$PROJECT_ID/topics/$topicId" + } + + private fun createSubscription(topicId: String, subscriptionId: String): CreateSubscriptionResponse = put { + target = TARGET + path = "v1/projects/$PROJECT_ID/subscriptions/$subscriptionId" + body = """{"topic":"projects/$PROJECT_ID/topics/$topicId"}""" + } + + companion object { + + @ClassRule + @JvmField + var docker: DockerComposeRule = DockerComposeRule.builder() + .file("src/test/resources/docker-compose.yaml") + .waitingForService("pubsub-emulator", HealthChecks.toHaveAllPortsOpen()) + .waitingForService("pubsub-emulator", + HealthChecks.toRespond2xxOverHttp(8085) { port -> + port.inFormat("http://\$HOST:\$EXTERNAL_PORT/") + }, + Duration.standardSeconds(40L)) + .build() + } +} + +class CreateTopicResponse(var name: String? = null) + +class CreateSubscriptionResponse( + var name: String? = null, + var topic: String? = null, + var pushConfig: Any? = null, + var ackDeadlineSeconds: Long? = null, + var messageRetentionDuration: String? = null) diff --git a/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/core/OnlineChargingTest.kt b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/core/OnlineChargingTest.kt new file mode 100644 index 000000000..a7fc07408 --- /dev/null +++ b/ocs-ktc/src/test/kotlin/org/ostelco/prime/ocs/core/OnlineChargingTest.kt @@ -0,0 +1,84 @@ +package org.ostelco.prime.ocs.core + +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.ocs.api.MultipleServiceCreditControl +import org.ostelco.ocs.api.ServiceUnit +import java.time.Instant +import java.util.* +import java.util.concurrent.CountDownLatch +import kotlin.test.fail + +class OnlineChargingTest { + + @Ignore + @Test + fun `load test OnlineCharging directly`() = runBlocking { + + // Add delay to DB call and skip analytics and low balance notification + OnlineCharging.loadUnitTest = true + + // count down latch to wait for all responses to return + val cdl = CountDownLatch(COUNT) + + // response handle which will count down on receiving response + val creditControlAnswerInfo: StreamObserver = object : StreamObserver { + + override fun onNext(value: CreditControlAnswerInfo?) { + // count down on receiving response + cdl.countDown() + } + + override fun onError(t: Throwable?) { + fail(t?.message) + } + + override fun onCompleted() { + + } + } + + // Sample request which will be sent repeatedly + val request = CreditControlRequestInfo.newBuilder() + .setRequestId(UUID.randomUUID().toString()) + .setMsisdn(MSISDN) + .addMscc(0, MultipleServiceCreditControl.newBuilder() + .setRequested(ServiceUnit.newBuilder().setTotalOctets(100)) + .setUsed(ServiceUnit.newBuilder().setTotalOctets(80))) + .build() + + // Start timestamp in millisecond + val start = Instant.now() + + // Send the same request COUNT times + repeat(COUNT) { + OnlineCharging.creditControlRequestEvent( + request = request, + returnCreditControlAnswer = creditControlAnswerInfo::onNext) + } + + // Wait for all the responses to be returned + println("Waiting for all responses to be returned") + + @Suppress("BlockingMethodInNonBlockingContext") + cdl.await() + + // Stop timestamp in millisecond + val stop = Instant.now() + + // Print load test results + val diff = stop.toEpochMilli() - start.toEpochMilli() + println("Time diff: %,d milli sec".format(diff)) + val rate = COUNT * 1000.0 / diff + println("Rate: %,.2f req/sec".format(rate)) + } + + companion object { + private const val COUNT = 1_000_000 + private const val MSISDN = "4790300147" + } +} \ No newline at end of file diff --git a/ocs-ktc/src/test/resources/META-INF/services/org.ostelco.prime.storage.DocumentStore b/ocs-ktc/src/test/resources/META-INF/services/org.ostelco.prime.storage.DocumentStore new file mode 100644 index 000000000..59f3a8384 --- /dev/null +++ b/ocs-ktc/src/test/resources/META-INF/services/org.ostelco.prime.storage.DocumentStore @@ -0,0 +1 @@ +org.ostelco.prime.ocs.MockDocumentStore diff --git a/ocs-ktc/src/test/resources/META-INF/services/org.ostelco.prime.storage.GraphStore b/ocs-ktc/src/test/resources/META-INF/services/org.ostelco.prime.storage.GraphStore new file mode 100644 index 000000000..584b50b2a --- /dev/null +++ b/ocs-ktc/src/test/resources/META-INF/services/org.ostelco.prime.storage.GraphStore @@ -0,0 +1 @@ +org.ostelco.prime.ocs.MockGraphStore diff --git a/ocs-ktc/src/test/resources/docker-compose.yaml b/ocs-ktc/src/test/resources/docker-compose.yaml new file mode 100644 index 000000000..2ee51d211 --- /dev/null +++ b/ocs-ktc/src/test/resources/docker-compose.yaml @@ -0,0 +1,8 @@ +version: "3.3" + +services: + pubsub-emulator: + container_name: pubsub-emulator + image: knarz/pubsub-emulator + ports: + - "8085:8085" \ No newline at end of file diff --git a/ocs-ktc/src/test/resources/logback-test.xml b/ocs-ktc/src/test/resources/logback-test.xml new file mode 100644 index 000000000..816969fce --- /dev/null +++ b/ocs-ktc/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + \ No newline at end of file diff --git a/ocs/build.gradle b/ocs/build.gradle index fa7caa3df..fa996c59d 100644 --- a/ocs/build.gradle +++ b/ocs/build.gradle @@ -1,23 +1,16 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } -kotlin { - experimental { - coroutines 'enable' - } -} - dependencies { implementation project(':prime-modules') implementation 'com.lmax:disruptor:3.4.2' - // implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:0.30.2" - + testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" testImplementation "org.mockito:mockito-core:$mockitoVersion" } -apply from: '../jacoco.gradle' \ No newline at end of file +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/ocs/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsReporter.kt b/ocs/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsReporter.kt index 0a0b19e8f..8853ee2fe 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsReporter.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsReporter.kt @@ -28,7 +28,7 @@ object AnalyticsReporter : EventHandler { if (msisdn != null) { logger.info("Sent Data Consumption info event to analytics") analyticsReporter.reportTrafficInfo( - msisdn = msisdn, + msisdnAnalyticsId = msisdn, usedBytes = event.request?.msccList?.firstOrNull()?.used?.totalOctets ?: 0L, bundleBytes = event.bundleBytes, apn = event.request?.serviceInformation?.psInformation?.calledStationId, diff --git a/ocs/src/main/kotlin/org/ostelco/prime/consumption/OcsEventToGrpcResponseMapper.kt b/ocs/src/main/kotlin/org/ostelco/prime/consumption/OcsEventToGrpcResponseMapper.kt index cbd493d39..5e28a90d8 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/consumption/OcsEventToGrpcResponseMapper.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/consumption/OcsEventToGrpcResponseMapper.kt @@ -83,8 +83,7 @@ internal class OcsEventToGrpcResponseMapper(private val ocsAsyncResponseProducer if (request.msccCount > 0) { val msccBuilder = MultipleServiceCreditControl.newBuilder() msccBuilder.setServiceIdentifier(request.getMscc(0).serviceIdentifier) - .setRatingGroup(request.getMscc(0).ratingGroup) - .setValidityTime(86400) + .setRatingGroup(request.getMscc(0).ratingGroup).validityTime = 86400 if ((request.getMscc(0).reportingReason != ReportingReason.FINAL) && (request.getMscc(0).requested.totalOctets > 0)) { msccBuilder.granted = ServiceUnit.newBuilder() diff --git a/ocs/src/main/kotlin/org/ostelco/prime/disruptor/EventProducerImpl.kt b/ocs/src/main/kotlin/org/ostelco/prime/disruptor/EventProducerImpl.kt index 88ff116cb..b4ef9556a 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/disruptor/EventProducerImpl.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/disruptor/EventProducerImpl.kt @@ -90,7 +90,7 @@ class EventProducerImpl(private val ringBuffer: RingBuffer) : EventPro request: CreditControlRequestInfo) { injectIntoRingBuffer( - eventMessageType =CREDIT_CONTROL_REQUEST, + eventMessageType = CREDIT_CONTROL_REQUEST, msisdn = request.msisdn, streamId = streamId, request = request) diff --git a/ocs/src/main/kotlin/org/ostelco/prime/disruptor/OcsEvent.kt b/ocs/src/main/kotlin/org/ostelco/prime/disruptor/OcsEvent.kt index 03c4816c1..90e2b9ed5 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/disruptor/OcsEvent.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/disruptor/OcsEvent.kt @@ -26,7 +26,7 @@ class OcsEvent { var reservedBucketBytes: Long = 0 /** - * Origin of word 'bundle' - Subscriber buys a 'bundle' of units of data/airtime/validity etc. + * Origin of word 'bundle' - Customer buys a 'bundle' of units of data/airtime/validity etc. * This field represent total balance bytes. */ var bundleBytes: Long = 0 @@ -51,7 +51,7 @@ class OcsEvent { reservedBucketBytes = 0 ocsgwStreamId = null request = null - topupContext = null; + topupContext = null } //FIXME vihang: We need to think about roaming diff --git a/ocs/src/main/kotlin/org/ostelco/prime/handler/PurchaseRequestHandler.kt b/ocs/src/main/kotlin/org/ostelco/prime/handler/PurchaseRequestHandler.kt index 2ed28397e..23f43d4fb 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/handler/PurchaseRequestHandler.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/handler/PurchaseRequestHandler.kt @@ -2,11 +2,13 @@ package org.ostelco.prime.handler import arrow.core.Either import arrow.core.flatMap +import arrow.core.right import com.lmax.disruptor.EventHandler import org.ostelco.prime.disruptor.EventMessageType.TOPUP_DATA_BUNDLE_BALANCE import org.ostelco.prime.disruptor.EventProducer import org.ostelco.prime.disruptor.OcsEvent import org.ostelco.prime.getLogger +import org.ostelco.prime.model.Identity import org.ostelco.prime.module.getResource import org.ostelco.prime.storage.ClientGraphStore import java.util.* @@ -23,13 +25,13 @@ class PurchaseRequestHandler( private val requestMap = ConcurrentHashMap>() fun handlePurchaseRequest( - subscriberId: String, + identity: Identity, productSku: String): Either { - logger.info("Handling purchase request - subscriberId: {} sku = {}", subscriberId, productSku) + logger.info("Handling purchase request - customer identity: {} sku = {}", identity, productSku) // get Product by SKU - return storage.getProduct(subscriberId, productSku) + return storage.getProduct(identity, productSku) // if left, map StoreError to String .mapLeft { "Unable to Topup. Not a valid SKU: $productSku. ${it.message}" @@ -47,13 +49,13 @@ class PurchaseRequestHandler( } // map noOfBytes to (noOfBytes, bundleId) .flatMap { noOfBytes -> - storage.getBundles(subscriberId) - .mapLeft { "Unable to Topup. No bundles found for subscriberId: $subscriberId" } + storage.getBundles(identity) + .mapLeft { "Unable to Topup. No bundles found for customer with identity: $identity" } .flatMap { bundles -> bundles.firstOrNull() ?.id ?.let { Either.right(Pair(noOfBytes, it)) } - ?: Either.left("Unable to Topup. No bundles or invalid bundle found for subscriberId: $subscriberId") + ?: Either.left("Unable to Topup. No bundles or invalid bundle found for customer with identity: $identity") } } .flatMap { (noOfBytes, bundleId) -> @@ -84,6 +86,6 @@ class PurchaseRequestHandler( if (error.isNotBlank()) { return Either.left(error) } - return Either.right(Unit) + return Unit.right() } } diff --git a/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsModule.kt b/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsModule.kt index dea4238de..dc32dadbb 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsModule.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsModule.kt @@ -37,9 +37,9 @@ class OcsModule : PrimeModule { val server = OcsGrpcServer(8082, ocsService.ocsGrpcService) // Events flow: - // Producer:(OcsService, Subscriber) + // Producer:(OcsService, Customer) // -> Handler:(OcsState) - // -> Handler:(OcsService, Subscriber, AnalyticsPublisher) + // -> Handler:(OcsService, Customer, AnalyticsPublisher) // -> Clear disruptor.disruptor @@ -58,9 +58,7 @@ class OcsModule : PrimeModule { } } -class OcsConfig { - +data class OcsConfig( @NotEmpty @JsonProperty("lowBalanceThreshold") - var lowBalanceThreshold: Long = 0 -} + var lowBalanceThreshold: Long = 0) diff --git a/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsPrimeService.kt b/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsPrimeService.kt index d5353b6ad..08d5d9794 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsPrimeService.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsPrimeService.kt @@ -5,6 +5,7 @@ import org.ostelco.prime.disruptor.EventProducer import org.ostelco.prime.handler.OcsStateUpdateHandler import org.ostelco.prime.handler.PurchaseRequestHandler import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Identity import org.ostelco.prime.module.getResource import org.ostelco.prime.storage.ClientDataSource @@ -26,8 +27,8 @@ object OcsPrimeServiceSingleton : OcsSubscriberService, OcsAdminService { ocsStateUpdateHandler = OcsStateUpdateHandler(producer) } - override fun topup(subscriberId: String, sku: String): Either { - return purchaseRequestHandler.handlePurchaseRequest(subscriberId, sku) + override fun topup(identity: Identity, sku: String): Either { + return purchaseRequestHandler.handlePurchaseRequest(identity, sku) } override fun addBundle(bundle: Bundle) { diff --git a/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsState.kt b/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsState.kt index c387aea9e..1e5c0a530 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsState.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/ocs/OcsState.kt @@ -23,7 +23,7 @@ class OcsState(val loadSubscriberInfo:Boolean = true) : EventHandler { // this is public for prime:integration tests val msisdnToBundleIdMap = HashMap() - val bundleIdToMsisdnMap = HashMap>() + private val bundleIdToMsisdnMap = HashMap>() private val bundleBalanceMap = HashMap() private val bucketReservedMap = HashMap() diff --git a/ocs/src/main/kotlin/org/ostelco/prime/thresholds/ThresholdChecker.kt b/ocs/src/main/kotlin/org/ostelco/prime/thresholds/ThresholdChecker.kt index a04068427..c0741e5a5 100644 --- a/ocs/src/main/kotlin/org/ostelco/prime/thresholds/ThresholdChecker.kt +++ b/ocs/src/main/kotlin/org/ostelco/prime/thresholds/ThresholdChecker.kt @@ -30,7 +30,8 @@ class ThresholdChecker(private val lowBalanceThreshold: Long) : EventHandler lowBalanceThreshold)) { val msisdn = event.msisdn if (msisdn != null) { - appNotifier.notify(msisdn, "Pi", "You have less then " + lowBalanceThreshold/1000000 + "Mb data left") + // FIXME should be customerId + // appNotifier.notify(msisdn, "Pi", "You have less then " + lowBalanceThreshold/1000000 + "Mb data left") } } } diff --git a/ocs/src/test/kotlin/org/ostelco/prime/disruptor/PrimeEventProducerTest.kt b/ocs/src/test/kotlin/org/ostelco/prime/disruptor/PrimeEventProducerTest.kt index 1471d5196..f861e7fca 100644 --- a/ocs/src/test/kotlin/org/ostelco/prime/disruptor/PrimeEventProducerTest.kt +++ b/ocs/src/test/kotlin/org/ostelco/prime/disruptor/PrimeEventProducerTest.kt @@ -125,9 +125,9 @@ class PrimeEventProducerTest { private const val TIMEOUT = 10 - private const val RATING_GROUP = 10L; + private const val RATING_GROUP = 10L - private const val SERVICE_IDENTIFIER = 1L; + private const val SERVICE_IDENTIFIER = 1L private const val TOPUP_REQUEST_ID = "req-id" } diff --git a/ocs/src/test/kotlin/org/ostelco/prime/event/EventProcessorTest.kt b/ocs/src/test/kotlin/org/ostelco/prime/event/EventProcessorTest.kt index c9ad87496..c6825dc50 100644 --- a/ocs/src/test/kotlin/org/ostelco/prime/event/EventProcessorTest.kt +++ b/ocs/src/test/kotlin/org/ostelco/prime/event/EventProcessorTest.kt @@ -9,18 +9,19 @@ import org.mockito.Mock import org.mockito.Mockito import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule +import org.ostelco.prime.disruptor.BundleBalanceStore import org.ostelco.prime.disruptor.EventMessageType.RELEASE_RESERVED_BUCKET import org.ostelco.prime.disruptor.OcsEvent -import org.ostelco.prime.disruptor.BundleBalanceStore import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.Product import org.ostelco.prime.storage.ClientDataSource import org.ostelco.prime.storage.StoreError import org.ostelco.prime.storage.legacy.Products -class EventProcessorTest { +class EventProcessorTest { - private val BUNDLE_ID = "foo@bar.com" + private val EMAIL = "foo@bar.com" private val NO_OF_BYTES = 4711L @Rule @@ -35,7 +36,10 @@ class EventProcessorTest { @Before fun setUp() { - Mockito.`when`>(storage.getProduct("id", Products.DATA_TOPUP_3GB.sku)) + Mockito.`when`>(storage + .getProduct( + Identity(id = EMAIL, type = "EMAIL", provider = "email"), + Products.DATA_TOPUP_3GB.sku)) .thenReturn(Either.right(Products.DATA_TOPUP_3GB)) this.processor = BundleBalanceStore(storage) @@ -45,14 +49,14 @@ class EventProcessorTest { fun testPrimeEventReleaseReservedDataBucket() { val primeEvent = OcsEvent() primeEvent.messageType = RELEASE_RESERVED_BUCKET - primeEvent.bundleId = BUNDLE_ID + primeEvent.bundleId = EMAIL primeEvent.bundleBytes = NO_OF_BYTES processor.onEvent(primeEvent, 0L, false) - Mockito.verify(storage).updateBundle(safeEq(Bundle(BUNDLE_ID, NO_OF_BYTES))) + Mockito.verify(storage).updateBundle(safeEq(Bundle(EMAIL, NO_OF_BYTES))) } - + // https://github.com/mockito/mockito/issues/1255 - fun safeEq(value: T): T = ArgumentMatchers.eq(value) ?: value + private fun safeEq(value: T): T = ArgumentMatchers.eq(value) ?: value } diff --git a/ocs/src/test/kotlin/org/ostelco/prime/handler/PurchaseRequestHandlerTest.kt b/ocs/src/test/kotlin/org/ostelco/prime/handler/PurchaseRequestHandlerTest.kt index b39a18150..57759e9fb 100644 --- a/ocs/src/test/kotlin/org/ostelco/prime/handler/PurchaseRequestHandlerTest.kt +++ b/ocs/src/test/kotlin/org/ostelco/prime/handler/PurchaseRequestHandlerTest.kt @@ -1,6 +1,7 @@ package org.ostelco.prime.handler import arrow.core.Either +import arrow.core.right import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Ignore @@ -14,6 +15,8 @@ import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule import org.ostelco.prime.disruptor.EventProducer +import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.Product import org.ostelco.prime.model.PurchaseRecord import org.ostelco.prime.storage.ClientDataSource @@ -37,8 +40,11 @@ class PurchaseRequestHandlerTest { @Before fun setUp() { - `when`< Either>(storage.getProduct(SUBSCRIBER_ID, DATA_TOPUP_3GB.sku)) - .thenReturn(Either.right(DATA_TOPUP_3GB)) + `when`< Either>(storage.getProduct(IDENTITY, DATA_TOPUP_3GB.sku)) + .thenReturn(DATA_TOPUP_3GB.right()) + + `when`< Either>>(storage.getBundles(IDENTITY)) + .thenReturn(listOf(Bundle(id = BUNDLE_ID, balance = 0)).right()) this.purchaseRequestHandler = PurchaseRequestHandler(producer, storage) } @@ -51,7 +57,7 @@ class PurchaseRequestHandlerTest { val sku = DATA_TOPUP_3GB.sku // Process a little - purchaseRequestHandler.handlePurchaseRequest(MSISDN, sku) + purchaseRequestHandler.handlePurchaseRequest(IDENTITY, sku) // Then verify that the appropriate actions has been performed. val topupBytes = DATA_TOPUP_3GB.properties["noOfBytes"]?.toLong() @@ -69,10 +75,9 @@ class PurchaseRequestHandlerTest { companion object { - private const val MSISDN = "12345678" - private const val SUBSCRIBER_ID = "foo@bar.com" private const val BUNDLE_ID = "foo@bar.com" private const val TOPUP_REQUEST_ID = "req-id" + private val IDENTITY = Identity(id = "foo@bar.com", type = "EMAIL", provider = "email") } // https://github.com/mockito/mockito/issues/1255 diff --git a/ocsgw/Dockerfile b/ocsgw/Dockerfile index b6b5264ee..033bd1dec 100644 --- a/ocsgw/Dockerfile +++ b/ocsgw/Dockerfile @@ -1,12 +1,17 @@ -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd && rm -rf /var/lib/apt/lists/* COPY script/start.sh /start.sh +COPY script/start_dev.sh /start_dev.sh +COPY script/wait_including_esps.sh /wait_including_esps.sh COPY script/wait.sh /wait.sh -COPY config /config + +COPY config/dictionary.xml /config/dictionary.xml +COPY config/logback.xml /config/logback.xml +COPY config/logback.dev.xml /config/logback.dev.xml COPY build/libs/ocsgw-uber.jar /ocsgw.jar diff --git a/ocsgw/README.md b/ocsgw/README.md index 32cecef23..374b8d04d 100644 --- a/ocsgw/README.md +++ b/ocsgw/README.md @@ -3,72 +3,96 @@ About the project ================= This project makes it possible to connect the Gy interface from a GGSN/P-GW to this OCS gateway. -The gateway will parse the Diameter traffic and pass it through to another component. -Currently this support a gRPC or a local adapter. +The gateway will parse the Diameter traffic and pass it through to the OCS component. +Currently it supports a gRPC, Local or Proxy datasource. -The Local adapter will accept all Credit-Control-Requests and send a Credit-Control-Answer that grant -any service unit requested. +The Local datasource will accept all Credit-Control-Requests and send a Credit-Control-Answer that grant +any service units requested. -The gRPC adapter will translate the Credit-Control-Request to gRPC and forward this to your gRPC server. +The gRPC datasource will translate the Credit-Control-Request to gRPC and forward this to the OCS server. + +The Proxy datasource is a combination of the Local and gRPC datasource that will forward all traffic to OCS using the +gRPC datasource but also the Local datasource to get low latency. Note that this project does not implement a full Online Charging System. -The project is built on RestComm jDiameter Stack +The project is built on RestComm jDiameter Stack. + +For diameter HA setup please see : [diameter-ha](../diameter-ha/README.md) Build =============== - -gradle build - +``` +./gradlew build +``` Run =============== - -gradle run - +``` +./gradlew run +``` Test -===================== -gradle test +=============== +``` +./gradlew test +``` + +Deploy to GCP +=============== +Please see the script for usage +``` +./ocsgw/infra/script/deploy-ocsgw.sh +``` Docker =============== **Build** +``` docker build -t ocsgw . +``` **Run** +``` docker run --rm --name ocsgw -p 3868:3868 ocsgw +``` -Testing with seagull +Testing ===================== + +Seagull can be used for load testing. Please see : [seagull](../seagull/README.md) **Build Seagull docker image:** in ./testsuite/seagull/docker +``` docker build -t seagull . +``` **Start Seagull** Check your local IP. -Update /config/conf.client with your local IP - +Update /config/conf.client with your local IP` +``` docker run --rm -it --net=host -v ./seagull/:/config -h ocs seagull +``` **Start OCSgw** -update IPAddress for your LocalPeer in /src/resources/server-jdiameter-config.xml with your local IP +Update IPAddress for your LocalPeer in /src/resources/server-jdiameter-config.xml with your local IP Start OCSgw **Run test** In Seagull: - +``` cd /config/logs -seagull -conf /config/config/conf.client.xml -dico /config/config/base_cc.xml -scen /config/scenario/ccr-cca.client.multiple-cc-units.init-term.xml -log /config/logs/log.log -llevel A +seagull -conf /config/config/conf.client.xml -dico /config/config/base_cc.xml -scen /config/scenario/ccr-cca.client.multiple-cc-units.init.xml -log /config/logs/log.log -llevel N +`` diff --git a/ocsgw/build.gradle b/ocsgw/build.gradle index 16d67c79a..08b94d39f 100644 --- a/ocsgw/build.gradle +++ b/ocsgw/build.gradle @@ -1,23 +1,29 @@ plugins { + id "org.jetbrains.kotlin.jvm" id "application" - // FIXME martin: unable to update to 4.0.1 - id "com.github.johnrengelman.shadow" version "2.0.4" + id "com.github.johnrengelman.shadow" version "5.0.0" } dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation project(':ocs-grpc-api') implementation project(':analytics-grpc-api') implementation project(':diameter-stack') + implementation project(':diameter-ha') + + implementation "com.google.cloud:google-cloud-pubsub:$googleCloudVersion" implementation "com.google.cloud:google-cloud-core-grpc:$googleCloudVersion" + implementation "com.google.cloud:google-cloud-storage:$googleCloudVersion" implementation "javax.xml.bind:jaxb-api:$jaxbVersion" - implementation "javax.activation:activation:$javaxActivationVersion" + runtimeOnly "javax.activation:activation:$javaxActivationVersion" implementation 'ch.qos.logback:logback-classic:1.2.3' - - // log to gcp stack-driver - implementation 'com.google.cloud:google-cloud-logging-logback:0.67.0-alpha' + implementation 'ch.qos.logback.contrib:logback-json-classic:0.1.5' + implementation 'ch.qos.logback.contrib:logback-jackson:0.1.5' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.8' testImplementation project(':diameter-test') testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5Version" @@ -34,6 +40,8 @@ shadowJar { version = null } +version = "1.1.27" + test { // native support to Junit5 in Gradle 4.6+ useJUnitPlatform { @@ -45,67 +53,4 @@ test { } } -task pack(dependsOn: ['packDev', 'packProd']) - -task packProd(type: Zip, dependsOn: 'shadowJar') { - from ('script/') { - into(project.name + '/script') - } - from ('config/dictionary.xml') { - into (project.name + '/config/') - } - from ('config/server-jdiameter-config.prod.xml') { - into (project.name + '/config/') - rename { String fileName -> - fileName.replace('prod.', '') - } - } - from ('config/pantel-prod.json') { - into (project.name + '/config/') - } - from ('config/logback.xml') { - into (project.name + '/config/') - } - from ('build/libs/ocsgw-uber.jar') { - into(project.name + '/build/libs/') - } - from ('Dockerfile') { - into(project.name) - } - archiveName = 'ocsgw.zip' - destinationDir = file('build/deploy/prod/') -} - -task packDev(type: Zip, dependsOn: 'shadowJar') { - from ('script/') { - into(project.name + '/script') - } - from ('config/logback.dev.xml') { - into (project.name + '/config/') - rename { String fileName -> - fileName.replace('dev.', '') - } - } - from ('config/dictionary.xml') { - into (project.name + '/config/') - } - from ('config/server-jdiameter-config.dev.xml') { - into (project.name + '/config/') - rename { String fileName -> - fileName.replace('dev.', '') - } - } - from ('config/pantel-prod.json') { - into (project.name + '/config/') - } - from ('build/libs/ocsgw-uber.jar') { - into(project.name + '/build/libs/') - } - from ('Dockerfile') { - into(project.name) - } - archiveName = 'ocsgw.zip' - destinationDir = file('build/deploy/dev/') -} - -apply from: '../jacoco.gradle' \ No newline at end of file +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/ocsgw/cert/.gitignore b/ocsgw/cert/.gitignore new file mode 100644 index 000000000..ee0cb61c4 --- /dev/null +++ b/ocsgw/cert/.gitignore @@ -0,0 +1 @@ +*.crt \ No newline at end of file diff --git a/ocsgw/config/.gitignore b/ocsgw/config/.gitignore index 893370e91..3b858adea 100644 --- a/ocsgw/config/.gitignore +++ b/ocsgw/config/.gitignore @@ -1,2 +1 @@ -pantel-prod.json -*.crt \ No newline at end of file +prime-service-account.json \ No newline at end of file diff --git a/ocsgw/config/dictionary.xml b/ocsgw/config/dictionary.xml index 6f7c02bc2..e94e4c7a7 100644 --- a/ocsgw/config/dictionary.xml +++ b/ocsgw/config/dictionary.xml @@ -7636,6 +7636,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/ocsgw/config/logback.dev.xml b/ocsgw/config/logback.dev.xml index d268423e5..00a92a74a 100644 --- a/ocsgw/config/logback.dev.xml +++ b/ocsgw/config/logback.dev.xml @@ -7,30 +7,20 @@ - - - INFO - - ocsgw - global - INFO - - - - - 1000 - - + + + + + -
\ No newline at end of file diff --git a/ocsgw/config/logback.xml b/ocsgw/config/logback.xml index cd20de856..d92b76375 100644 --- a/ocsgw/config/logback.xml +++ b/ocsgw/config/logback.xml @@ -7,29 +7,17 @@ - - - INFO - - ocsgw - global - INFO - - - - - 1000 - - - - + + - + + + + - \ No newline at end of file diff --git a/ocsgw/config/server-jdiameter-config.dev.xml b/ocsgw/config/server-jdiameter-config.dev.xml deleted file mode 100644 index e197b71ac..000000000 --- a/ocsgw/config/server-jdiameter-config.dev.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ocsgw/config/server-jdiameter-config.prod.xml b/ocsgw/config/server-jdiameter-config.prod.xml deleted file mode 100644 index bf2e2578d..000000000 --- a/ocsgw/config/server-jdiameter-config.prod.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ocsgw/config/server-jdiameter-config.xml b/ocsgw/config/server-jdiameter-config.xml index 7b3a33c9b..f8e0312f0 100644 --- a/ocsgw/config/server-jdiameter-config.xml +++ b/ocsgw/config/server-jdiameter-config.xml @@ -13,9 +13,9 @@ - - - + + + @@ -26,6 +26,7 @@ + @@ -35,6 +36,11 @@ + + + + + @@ -43,8 +49,11 @@ + + + @@ -55,17 +64,16 @@ - + - - + - + diff --git a/ocsgw/docker-compose.yaml b/ocsgw/docker-compose.yaml new file mode 100644 index 000000000..103ebf4fe --- /dev/null +++ b/ocsgw/docker-compose.yaml @@ -0,0 +1,19 @@ +version: "3.7" + +services: + ocsgw: + container_name: ocsgw + build: . + command: ["./start_dev.sh"] + environment: + - OCS_GRPC_SERVER=ocs.dev.oya.world + - METRICS_GRPC_SERVER=metrics.dev.oya.world + - SERVICE_FILE=prime-service-account.json + - GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID} + - PUBSUB_PROJECT_ID=${GCP_PROJECT_ID} + - PUBSUB_CCR_TOPIC_ID=ocs-ccr + - PUBSUB_CCA_TOPIC_ID=ocs-cca + - PUBSUB_CCA_SUBSCRIPTION_ID=ocsgw-cca-sub + - PUBSUB_ACTIVATE_SUBSCRIPTION_ID=ocsgw-activate-sub + volumes: + - ./config:/config/ \ No newline at end of file diff --git a/ocsgw/infra/script/deploy-ocsgw.sh b/ocsgw/infra/script/deploy-ocsgw.sh new file mode 100755 index 000000000..c86acca89 --- /dev/null +++ b/ocsgw/infra/script/deploy-ocsgw.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +# Script to deploy the OCS-gw to GCP Compute engine container + +##### Constants + +GCP_PROJECT_ID="$(gcloud config get-value project -q)" +OCSGW_VERSION="$(./gradlew ocsgw:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" +SHORT_SHA="$(git log -1 --pretty=format:%h)" +TAG_OCS="${OCSGW_VERSION}-${SHORT_SHA}" + +##### Functions + +checkRegion () { + allKnownRegions=("europe-west1" "europe-west4" "asia-southeast1") + + for knownRegion in "${allKnownRegions[@]}"; + do + if [[ ${knownRegion} == ${REGION} ]] + then + return 0 + fi + done + return 1 +} + +checkEnvironment () { + allKnownEnvironments=("dev" "prod") + + for knownEnvironment in "${allKnownEnvironments[@]}"; + do + if [[ ${knownEnvironment} == ${ENVIRONMENT} ]] + then + return 0 + fi + done + return 1 +} + +deploy () { + + echo + echo "*******************************" + echo "Deploying OCS-gw" + echo "Instance : ocsgw-${ENVIRONMENT}-${REGION}-${ZONE}-${INSTANCE}" + echo "*******************************" + echo + + gcloud compute instances update-container --zone ${REGION}-${ZONE} ocsgw-${ENVIRONMENT}-${REGION}-${ZONE}-${INSTANCE} \ + --container-image eu.gcr.io/${GCP_PROJECT_ID}/ocsgw:${TAG_OCS} +} + +printInfo() { + +echo +echo "Deployment script for OCS-gw to Google Cloud" +echo +echo "*******************************" +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} +echo OCSGW_VERSION=${OCSGW_VERSION} +echo SHORT_SHA=${SHORT_SHA} +echo TAG_OCS=${TAG_OCS} +echo "*******************************" +echo + +} + +printEnvironment() { + +echo +echo "*******************************" +echo ENVIRONMENT=${ENVIRONMENT} +echo REGION=${REGION} +echo ZONE=${ZONE} +echo "*******************************" +echo + +} + +##### Main + +set -e + +if [[ ! -f ocsgw/infra/script/deploy-ocsgw.sh ]]; then + (>&2 echo "Run this script from project root dir (ostelco-core)") + exit 1 +fi + +printInfo + +# Environment can be passed as first parameter ( dev / prod ) : default [dev] +if [[ ! -z "$1" ]]; then + ENVIRONMENT=$1 +else + ENVIRONMENT="dev" +fi + +# Region can be passed as second parameter ( europe-west1 /europe-west4 / asia-southeast1 ) : default [europe-west4] +if [[ ! -z "$2" ]]; then + REGION=$2 +else + REGION="europe-west4" +fi + +# Zone can be passed as third parameter (a/b/c/d/e/f) : default [b] +if [[ ! -z "$3" ]]; then + ZONE=$3 +else + ZONE="b" +fi + +# Instance number can be passed as forth parameter (1...n) : default [1] +if [[ ! -z "$4" ]]; then + INSTANCE=$4 +else + INSTANCE=1 +fi + + +if ! checkEnvironment; +then + echo "Not a valid environment : "${ENVIRONMENT} + exit 1 +fi + +if ! checkRegion; +then + echo "Not a valid region : ${REGION}" + exit 1 +fi + +printEnvironment + + +echo "Building OCS-gw" +./gradlew ocsgw:clean ocsgw:build +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/ocsgw:${TAG_OCS} ocsgw + +echo "Uploading Docker image" +docker push eu.gcr.io/${GCP_PROJECT_ID}/ocsgw:${TAG_OCS} + + +deploy diff --git a/ocsgw/script/start.sh b/ocsgw/script/start.sh index 60ca513b6..b220659dc 100755 --- a/ocsgw/script/start.sh +++ b/ocsgw/script/start.sh @@ -4,4 +4,4 @@ exec java \ -Dfile.encoding=UTF-8 \ -Dlogback.configurationFile=/config/logback.xml \ - -jar /ocsgw.jar + -jar /ocsgw.jar \ No newline at end of file diff --git a/ocsgw/script/start_dev.sh b/ocsgw/script/start_dev.sh new file mode 100755 index 000000000..2b38e4ffc --- /dev/null +++ b/ocsgw/script/start_dev.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Start app for production +exec java \ + -Dfile.encoding=UTF-8 \ + -Dlogback.configurationFile=/config/logback.dev.xml \ + -jar /ocsgw.jar \ No newline at end of file diff --git a/ocsgw/script/wait.sh b/ocsgw/script/wait.sh index 94f70ee20..e876a2428 100755 --- a/ocsgw/script/wait.sh +++ b/ocsgw/script/wait.sh @@ -2,17 +2,9 @@ set -e -echo "OCSGW waiting ESP to launch on 80..." +echo "OCSGW waiting Prime to launch on 8080..." -while ! nc -z 172.16.238.4 80; do - sleep 0.1 # wait for 1/10 of the second before check again -done - -echo "ESP launched" - -echo "OCSGW waiting Prime to launch on 8082..." - -while ! nc -z 172.16.238.5 8082; do +while ! nc -z prime 8080; do sleep 0.1 # wait for 1/10 of the second before check again done @@ -21,5 +13,6 @@ echo "Prime launched" # Start app for testing exec java \ -Dfile.encoding=UTF-8 \ + -Dlogback.configurationFile=/config/logback.xml \ -jar /ocsgw.jar diff --git a/ocsgw/script/wait_including_esps.sh b/ocsgw/script/wait_including_esps.sh new file mode 100755 index 000000000..bca9119b0 --- /dev/null +++ b/ocsgw/script/wait_including_esps.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +set -e + +echo "OCSGW waiting Prime to launch on 8082..." + +while ! nc -z prime 8082; do + sleep 0.1 # wait for 1/10 of the second before check again +done + +echo "Prime launched" + +echo "OCSGW waiting ESP to launch on 80..." + +while ! nc -z ocs.dev.ostelco.org 80; do + sleep 0.1 # wait for 1/10 of the second before check again +done + +echo "ESP launched" + +echo "OCSGW waiting Metrics ESP to launch on 80..." + +while ! nc -z metrics.dev.ostelco.org 80; do + sleep 0.1 # wait for 1/10 of the second before check again +done + +echo "Metrics ESP launched" + +# Start app for testing +exec java \ + -Dfile.encoding=UTF-8 \ + -Dlogback.configurationFile=/config/logback.xml \ + -jar /ocsgw.jar + diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/OcsApplication.java b/ocsgw/src/main/java/org/ostelco/ocsgw/OcsApplication.java index f79887159..06d265621 100644 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/OcsApplication.java +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/OcsApplication.java @@ -1,15 +1,7 @@ package org.ostelco.ocsgw; -import org.jdiameter.api.Answer; -import org.jdiameter.api.ApplicationId; -import org.jdiameter.api.Configuration; -import org.jdiameter.api.IllegalDiameterStateException; -import org.jdiameter.api.InternalException; -import org.jdiameter.api.Mode; -import org.jdiameter.api.Network; -import org.jdiameter.api.NetworkReqListener; -import org.jdiameter.api.Request; -import org.jdiameter.api.Stack; +import com.google.cloud.storage.*; +import org.jdiameter.api.*; import org.jdiameter.api.cca.ServerCCASession; import org.jdiameter.api.cca.events.JCreditControlRequest; import org.jdiameter.client.api.ISessionFactory; @@ -22,6 +14,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Arrays; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -30,7 +24,9 @@ public class OcsApplication extends CCASessionFactoryImpl implements NetworkReqL private static final Logger LOG = LoggerFactory.getLogger(OcsApplication.class); private static final String DIAMETER_CONFIG_FILE = "server-jdiameter-config.xml"; + private static final String CONFIG_FOLDER = "/config/"; private static final long APPLICATION_ID = 4L; // Diameter Credit Control Application (4) + private static final long VENDOR_ID_3GPP = 10415; private static Stack stack = null; public static void main(String[] args) { @@ -38,7 +34,18 @@ public static void main(String[] args) { Runtime.getRuntime().addShutdownHook(new Thread(OcsApplication::shutdown)); OcsApplication app = new OcsApplication(); - app.start("/config/"); + + String configFile = System.getenv("DIAMETER_CONFIG_FILE"); + if (configFile == null) { + configFile = DIAMETER_CONFIG_FILE; + } + + String configFolder = System.getenv("CONFIG_FOLDER"); + if (configFolder == null) { + configFolder = CONFIG_FOLDER; + } + + app.start(configFolder, configFile); } @@ -46,7 +53,7 @@ public static void shutdown() { LOG.info("Shutting down OcsApplication..."); if (stack != null) { try { - stack.stop(30000, TimeUnit.MILLISECONDS ,0); + stack.stop(30000, TimeUnit.MILLISECONDS , DisconnectCause.REBOOTING); } catch (IllegalDiameterStateException | InternalException e) { LOG.error("Failed to gracefully shutdown OcsApplication", e); } @@ -54,20 +61,45 @@ public static void shutdown() { } } - public void start(final String configDir) { + private void fetchConfig(final String configDir, final String configFile) { + final String vpcEnv = System.getenv("VPC_ENV"); + final String instance = System.getenv("INSTANCE"); + final String serviceFile = System.getenv("SERVICE_FILE"); + + if ((vpcEnv != null) && (instance != null) && (serviceFile != null)) { + + final String bucket = "ocsgw-" + vpcEnv + "-" + instance + "-bucket"; + + Storage storage = StorageOptions.getDefaultInstance().getService(); + + Blob blobConfigFile = storage.get(BlobId.of(bucket, configFile)); + final Path destConfigurationFilePath = Paths.get(configDir + "/" + configFile); + blobConfigFile.downloadTo(destConfigurationFilePath); + + Blob blobServiceAccountFile = storage.get(BlobId.of(bucket, serviceFile)); + final Path destServiceAccountFilePath = Paths.get(configDir + "/" + serviceFile); + blobServiceAccountFile.downloadTo(destServiceAccountFilePath); + } + } + + + public void start(final String configDir, final String configFile) { try { - Configuration diameterConfig = new XMLConfiguration(configDir + DIAMETER_CONFIG_FILE); + + fetchConfig(configDir, configFile); + + Configuration diameterConfig = new XMLConfiguration(configDir + configFile); stack = new StackImpl(); - stack.init(diameterConfig); + sessionFactory = (ISessionFactory) stack.init(diameterConfig); - OcsServer.getInstance().init(stack, new AppConfig()); + OcsServer.INSTANCE.init$ocsgw(stack, new AppConfig()); Network network = stack.unwrap(Network.class); - network.addNetworkReqListener(this, ApplicationId.createByAuthAppId(APPLICATION_ID)); + network.addNetworkReqListener(this, ApplicationId.createByAuthAppId(0L, APPLICATION_ID)); + network.addNetworkReqListener(this, ApplicationId.createByAuthAppId(VENDOR_ID_3GPP, APPLICATION_ID)); stack.start(Mode.ALL_PEERS, 30000, TimeUnit.MILLISECONDS); - sessionFactory = (ISessionFactory) stack.getSessionFactory(); init(sessionFactory); sessionFactory.registerAppFacory(ServerCCASession.class, this); printAppIds(); @@ -79,11 +111,11 @@ public void start(final String configDir) { @Override public Answer processRequest(Request request) { - LOG.info("<< Received Request"); + LOG.info("<< Received Request [{}]", request.getSessionId()); try { - ServerCCASessionImpl session = - (sessionFactory).getNewAppSession(request.getSessionId(), ApplicationId.createByAuthAppId(4L), ServerCCASession.class); + ServerCCASessionImpl session = sessionFactory.getNewAppSession(request.getSessionId(), ApplicationId.createByAuthAppId(4L), ServerCCASession.class); session.processRequest(request); + LOG.info("processRequest finished [{}]", request.getSessionId()); } catch (InternalException e) { LOG.error(">< Failure handling received request.", e); @@ -99,9 +131,9 @@ public void doCreditControlRequest(ServerCCASession session, JCreditControlReque case RequestType.INITIAL_REQUEST: case RequestType.UPDATE_REQUEST: case RequestType.TERMINATION_REQUEST: - LOG.info("<< Received Credit-Control-Request from P-GW [ {} ]", RequestType.getTypeAsString(request.getRequestTypeAVPValue())); + LOG.info("<< Received Credit-Control-Request from P-GW [ {} ] [{}]", RequestType.getTypeAsString(request.getRequestTypeAVPValue()), session.getSessionId()); try { - OcsServer.getInstance().handleRequest(session, request); + OcsServer.INSTANCE.handleRequest$ocsgw(session, request); } catch (Exception e) { LOG.error(">< Failure processing Credit-Control-Request [" + RequestType.getTypeAsString(request.getRequestTypeAVPValue()) + "]", e); } diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/OcsServer.java b/ocsgw/src/main/java/org/ostelco/ocsgw/OcsServer.java deleted file mode 100644 index 6a757aa29..000000000 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/OcsServer.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.ostelco.ocsgw; - -import org.jdiameter.api.ApplicationId; -import org.jdiameter.api.Avp; -import org.jdiameter.api.AvpSet; -import org.jdiameter.api.IllegalDiameterStateException; -import org.jdiameter.api.InternalException; -import org.jdiameter.api.OverloadException; -import org.jdiameter.api.Request; -import org.jdiameter.api.RouteException; -import org.jdiameter.api.Session; -import org.jdiameter.api.Stack; -import org.jdiameter.api.auth.events.ReAuthRequest; -import org.jdiameter.api.cca.ServerCCASession; -import org.jdiameter.api.cca.events.JCreditControlRequest; -import org.jdiameter.common.impl.app.auth.ReAuthRequestImpl; -import org.jdiameter.server.impl.app.cca.ServerCCASessionImpl; -import org.ostelco.diameter.CreditControlContext; -import org.ostelco.diameter.model.SessionContext; -import org.ostelco.diameter.model.ReAuthRequestType; -import org.ostelco.ocsgw.data.DataSource; -import org.ostelco.ocsgw.data.DataSourceType; -import org.ostelco.ocsgw.data.grpc.GrpcDataSource; -import org.ostelco.ocsgw.data.local.LocalDataSource; -import org.ostelco.ocsgw.data.proxy.ProxyDataSource; -import org.ostelco.ocsgw.utils.AppConfig; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; - - -public class OcsServer { - - private static final Logger LOG = LoggerFactory.getLogger(OcsApplication.class); - private static final OcsServer INSTANCE = new OcsServer(); - private Stack stack; - private DataSource source; - - public static OcsServer getInstance() { - return INSTANCE; - } - - private OcsServer() { - } - - public synchronized void handleRequest(ServerCCASession session, JCreditControlRequest request) { - - final CreditControlContext ccrContext = new CreditControlContext( - session.getSessionId(), - request, - stack.getMetaData().getLocalPeer().getUri().getFQDN(), - stack.getMetaData().getLocalPeer().getRealmName() - ); - source.handleRequest(ccrContext); - } - - public Stack getStack() { - return stack; - } - - //https://tools.ietf.org/html/rfc4006#page-30 - //https://tools.ietf.org/html/rfc3588#page-101 - public void sendReAuthRequest(final SessionContext sessionContext) { - try { - ServerCCASessionImpl ccaSession = stack.getSession(sessionContext.getSessionId(), ServerCCASessionImpl.class); - if (ccaSession != null && ccaSession.isValid()) { - // TODO martin: Not sure why there are multiple sessions for one session Id. - for (Session session : ccaSession.getSessions()) { - if (session.isValid()) { - Request request = session.createRequest(258, - ApplicationId.createByAuthAppId(4L), - sessionContext.getOriginRealm(), - sessionContext.getOriginHost() - ); - AvpSet avps = request.getAvps(); - avps.addAvp(Avp.RE_AUTH_REQUEST_TYPE, ReAuthRequestType.AUTHORIZE_ONLY.ordinal(), true, false); - ReAuthRequest reAuthRequest = new ReAuthRequestImpl(request); - ccaSession.sendReAuthRequest(reAuthRequest); - } else { - LOG.info("Invalid session"); - } - } - } else { - LOG.info("No session with ID {}", sessionContext.getSessionId()); - } - } catch (InternalException | IllegalDiameterStateException | RouteException | OverloadException e) { - LOG.warn("Failed to send Re-Auth Request", e); - } - } - - public void init(Stack stack, AppConfig appConfig) throws IOException { - this.stack = stack; - - switch (appConfig.getDataStoreType()) { - case DataSourceType.GRPC: - LOG.info("Using GrpcDataSource"); - source = new GrpcDataSource(appConfig.getGrpcServer(), appConfig.getMetricsServer()); - break; - case DataSourceType.LOCAL: - LOG.info("Using LocalDataSource"); - source = new LocalDataSource(); - break; - case DataSourceType.PROXY: - LOG.info("Using ProxyDataSource"); - GrpcDataSource secondary = new GrpcDataSource(appConfig.getGrpcServer(), appConfig.getMetricsServer()); - secondary.init(); - source = new ProxyDataSource(secondary); - break; - default: - LOG.warn("Unknown DataStoreType {}", appConfig.getDataStoreType()); - source = new LocalDataSource(); - break; - } - source.init(); - } -} diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/OcsServer.kt b/ocsgw/src/main/java/org/ostelco/ocsgw/OcsServer.kt new file mode 100644 index 000000000..a19ea2c5d --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/OcsServer.kt @@ -0,0 +1,165 @@ +package org.ostelco.ocsgw + +import org.jdiameter.api.ApplicationId +import org.jdiameter.api.Avp +import org.jdiameter.api.IllegalDiameterStateException +import org.jdiameter.api.InternalException +import org.jdiameter.api.OverloadException +import org.jdiameter.api.RouteException +import org.jdiameter.api.Stack +import org.jdiameter.api.cca.ServerCCASession +import org.jdiameter.api.cca.events.JCreditControlRequest +import org.jdiameter.common.impl.app.auth.ReAuthRequestImpl +import org.jdiameter.server.impl.app.cca.ServerCCASessionImpl +import org.ostelco.diameter.CreditControlContext +import org.ostelco.diameter.getLogger +import org.ostelco.diameter.model.ReAuthRequestType +import org.ostelco.diameter.model.SessionContext +import org.ostelco.ocsgw.datasource.DataSource +import org.ostelco.ocsgw.datasource.DataSourceType.Local +import org.ostelco.ocsgw.datasource.DataSourceType.Proxy +import org.ostelco.ocsgw.datasource.DataSourceType.PubSub +import org.ostelco.ocsgw.datasource.DataSourceType.gRPC +import org.ostelco.ocsgw.datasource.SecondaryDataSourceType +import org.ostelco.ocsgw.datasource.local.LocalDataSource +import org.ostelco.ocsgw.datasource.protobuf.GrpcDataSource +import org.ostelco.ocsgw.datasource.protobuf.ProtobufDataSource +import org.ostelco.ocsgw.datasource.protobuf.PubSubDataSource +import org.ostelco.ocsgw.datasource.proxy.ProxyDataSource +import org.ostelco.ocsgw.utils.AppConfig + + +object OcsServer { + + private val logger by getLogger() + + var stack: Stack? = null + private set + + private var source: DataSource? = null + private var localPeerFQDN: String? = null + private var localPeerRealm: String? = null + private var defaultRequestedServiceUnit: Long = 0L + + internal fun handleRequest(session: ServerCCASession, request: JCreditControlRequest) { + try { + val ccrContext = CreditControlContext( + session.sessionId, + request, + localPeerFQDN!!, + localPeerRealm!! + ) + setDefaultRequestedServiceUnit(ccrContext) + source?.handleRequest(ccrContext) ?: logger.error("Received request before initialising stack") + } catch (e: Exception) { + logger.error("Failed to create CreditControlContext", e) + } + } + + // In the case where the Diameter client does not set the Requested-Service-Unit AVP + // in the Multiple-Service-Credit-Control we need to set a default value. + private fun setDefaultRequestedServiceUnit(ccrContext: CreditControlContext) { + ccrContext.creditControlRequest.multipleServiceCreditControls.forEach { mscc -> + if ( mscc.requested.size == 1 ) { + if ( mscc.requested.get(0).total <= 0) { + mscc.requested.get(0).total = defaultRequestedServiceUnit + } + } + } + } + + //https://tools.ietf.org/html/rfc4006#page-30 + //https://tools.ietf.org/html/rfc3588#page-101 + fun sendReAuthRequest(sessionContext: SessionContext?) { + try { + val ccaSession = stack?.getSession(sessionContext?.sessionId, ServerCCASessionImpl::class.java) + if (ccaSession != null && ccaSession.isValid) { + // TODO martin: Not sure why there are multiple sessions for one session Id. + for (session in ccaSession.sessions) { + if (session.isValid) { + val request = session.createRequest(258, + ApplicationId.createByAuthAppId(4L), + sessionContext?.originRealm, + sessionContext?.originHost + ) + val avps = request.avps + avps.addAvp(Avp.RE_AUTH_REQUEST_TYPE, ReAuthRequestType.AUTHORIZE_ONLY.ordinal, true, false) + val reAuthRequest = ReAuthRequestImpl(request) + ccaSession.sendReAuthRequest(reAuthRequest) + } else { + logger.info("Invalid session") + } + } + } else { + logger.info("No session with ID {}", sessionContext?.sessionId) + } + } catch (e: InternalException) { + logger.warn("Failed to send Re-Auth Request", e) + } catch (e: IllegalDiameterStateException) { + logger.warn("Failed to send Re-Auth Request", e) + } catch (e: RouteException) { + logger.warn("Failed to send Re-Auth Request", e) + } catch (e: OverloadException) { + logger.warn("Failed to send Re-Auth Request", e) + } + + } + + internal fun init(stack: Stack, appConfig: AppConfig) { + this.stack = stack + this.localPeerFQDN = stack.metaData.localPeer.uri.fqdn + this.localPeerRealm = stack.metaData.localPeer.realmName + + this.defaultRequestedServiceUnit = appConfig.defaultRequestedServiceUnit + + val protobufDataSource = ProtobufDataSource() + + source = when (appConfig.dataStoreType) { + Proxy -> { + logger.info("Using ProxyDataSource") + val secondary = when (appConfig.secondaryDataStoreType) { + SecondaryDataSourceType.PubSub -> getPubSubDataSource(protobufDataSource, appConfig) + SecondaryDataSourceType.gRPC -> getGrpcDataSource(protobufDataSource, appConfig) + else -> getPubSubDataSource(protobufDataSource, appConfig) + } + secondary.init() + ProxyDataSource(secondary) + } + Local -> { + logger.info("Using LocalDataSource") + LocalDataSource() + } + PubSub -> { + logger.info("Using PubSubDataSource") + getPubSubDataSource(protobufDataSource, appConfig) + } + gRPC -> { + logger.info("Using GrpcDataSource") + getGrpcDataSource(protobufDataSource, appConfig) + } + else -> { + logger.warn("Unknown DataStoreType {}", appConfig.dataStoreType) + LocalDataSource() + } + } + source?.init() + } + + private fun getGrpcDataSource( + protobufDataSource: ProtobufDataSource, + appConfig: AppConfig): GrpcDataSource = + GrpcDataSource( + protobufDataSource, + appConfig.grpcServer, + appConfig.metricsServer) + + private fun getPubSubDataSource( + protobufDataSource: ProtobufDataSource, + appConfig: AppConfig): PubSubDataSource = + PubSubDataSource(protobufDataSource, + appConfig.pubSubProjectId, + appConfig.pubSubTopicIdForCcr, + appConfig.pubSubTopicIdForCca, + appConfig.pubSubSubscriptionIdForCca, + appConfig.pubSubSubscriptionIdForActivate) +} \ No newline at end of file diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/converter/ProtobufToDiameterConverter.java b/ocsgw/src/main/java/org/ostelco/ocsgw/converter/ProtobufToDiameterConverter.java new file mode 100644 index 000000000..e6f986c44 --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/converter/ProtobufToDiameterConverter.java @@ -0,0 +1,144 @@ +package org.ostelco.ocsgw.converter; + +import org.ostelco.diameter.CreditControlContext; +import org.ostelco.diameter.model.*; +import org.ostelco.ocs.api.CreditControlRequestInfo; +import org.ostelco.ocs.api.CreditControlRequestType; +import org.ostelco.ocs.api.ServiceInfo; +import org.ostelco.ocsgw.datasource.protobuf.GrpcDataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import java.util.Collections; + +import static org.ostelco.diameter.model.RequestType.*; + +public class ProtobufToDiameterConverter { + + private static final Logger LOG = LoggerFactory.getLogger(GrpcDataSource.class); + + /** + * Convert MultipleServiceCreditControl in gRPC format to diameter format + */ + public static MultipleServiceCreditControl convertMSCC(org.ostelco.ocs.api.MultipleServiceCreditControl msccGRPC) { + return new MultipleServiceCreditControl( + msccGRPC.getRatingGroup(), + (int) msccGRPC.getServiceIdentifier(), + Collections.singletonList(new ServiceUnit()), new ServiceUnit(), new ServiceUnit(msccGRPC.getGranted().getTotalOctets(), 0, 0), + msccGRPC.getValidityTime(), + msccGRPC.getQuotaHoldingTime(), + msccGRPC.getVolumeQuotaThreshold(), + convertFinalUnitIndication(msccGRPC.getFinalUnitIndication()), + convertResultCode(msccGRPC.getResultCode())); + } + + /** + * Convert Diameter request type to gRPC + */ + public static CreditControlRequestType getRequestType(CreditControlContext context) { + switch (context.getOriginalCreditControlRequest().getRequestTypeAVPValue()) { + case INITIAL_REQUEST: + return CreditControlRequestType.INITIAL_REQUEST; + case UPDATE_REQUEST: + return CreditControlRequestType.UPDATE_REQUEST; + case TERMINATION_REQUEST: + return CreditControlRequestType.TERMINATION_REQUEST; + case EVENT_REQUEST: + return CreditControlRequestType.EVENT_REQUEST; + default: + LOG.warn("Unknown request type"); + return CreditControlRequestType.NONE; + } + } + + private static FinalUnitIndication convertFinalUnitIndication(org.ostelco.ocs.api.FinalUnitIndication fuiGrpc) { + if (!fuiGrpc.getIsSet()) { + return null; + } + return new FinalUnitIndication( + FinalUnitAction.values()[fuiGrpc.getFinalUnitAction().getNumber()], + fuiGrpc.getRestrictionFilterRuleList(), + fuiGrpc.getFilterIdList(), + new RedirectServer( + RedirectAddressType.values()[fuiGrpc.getRedirectServer().getRedirectAddressType().getNumber()], + fuiGrpc.getRedirectServer().getRedirectServerAddress() + ) + ); + } + + // We match the error codes on names in gRPC and internal model + public static ResultCode convertResultCode(org.ostelco.ocs.api.ResultCode resultCode) { + return ResultCode.valueOf(resultCode.name()); + } + + public static CreditControlRequestInfo convertRequestToProtobuf(final CreditControlContext context, @Nullable final String topicId) { + + try { + CreditControlRequestInfo.Builder builder = CreditControlRequestInfo + .newBuilder() + .setType(getRequestType(context)); + + if (topicId != null) { + builder.setTopicId(topicId); + } + + for (MultipleServiceCreditControl mscc : context.getCreditControlRequest().getMultipleServiceCreditControls()) { + + org.ostelco.ocs.api.MultipleServiceCreditControl.Builder protoMscc = org.ostelco.ocs.api.MultipleServiceCreditControl.newBuilder(); + + if (!mscc.getRequested().isEmpty()) { + + ServiceUnit requested = mscc.getRequested().get(0); + + protoMscc.setRequested(org.ostelco.ocs.api.ServiceUnit.newBuilder() + .setTotalOctets(requested.getTotal()) // fails at 55904 + .setInputOctets(0L) + .setOutputOctets(0L)); + } + + ServiceUnit used = mscc.getUsed(); + + protoMscc.setUsed(org.ostelco.ocs.api.ServiceUnit.newBuilder() + .setInputOctets(used.getInput()) + .setOutputOctets(used.getOutput()) + .setTotalOctets(used.getTotal())); + + protoMscc.setRatingGroup(mscc.getRatingGroup()); + protoMscc.setServiceIdentifier(mscc.getServiceIdentifier()); + + if (mscc.getReportingReason() != null) { + protoMscc.setReportingReasonValue(mscc.getReportingReason().ordinal()); + } else { + protoMscc.setReportingReasonValue(org.ostelco.ocs.api.ReportingReason.UNRECOGNIZED.ordinal()); + } + builder.addMscc(protoMscc); + } + + builder.setRequestId(context.getSessionId()) + .setMsisdn(context.getCreditControlRequest().getMsisdn()) + .setImsi(context.getCreditControlRequest().getImsi()); + + if (!context.getCreditControlRequest().getServiceInformation().isEmpty()) { + final PsInformation psInformation + = context.getCreditControlRequest().getServiceInformation().get(0).getPsInformation().get(0); + + if (psInformation != null + && psInformation.getCalledStationId() != null + && psInformation.getSgsnMccMnc() != null) { + + builder.setServiceInformation( + ServiceInfo.newBuilder() + .setPsInformation(org.ostelco.ocs.api.PsInformation.newBuilder() + .setCalledStationId(psInformation.getCalledStationId()) + .setSgsnMccMnc(psInformation.getSgsnMccMnc()))); + } + } + return builder.build(); + + } catch (Exception e) { + LOG.error("Failed to create CreditControlRequestInfo", e); + } + return null; + } +} diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/data/DataSourceType.java b/ocsgw/src/main/java/org/ostelco/ocsgw/data/DataSourceType.java deleted file mode 100644 index 7e49ee5fc..000000000 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/data/DataSourceType.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.ostelco.ocsgw.data; - -public class DataSourceType { - public static final String LOCAL = "Local"; - public static final String GRPC = "gRPC"; - public static final String PROXY = "Proxy"; -} diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/data/grpc/GrpcDataSource.java b/ocsgw/src/main/java/org/ostelco/ocsgw/data/grpc/GrpcDataSource.java deleted file mode 100644 index a320da85c..000000000 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/data/grpc/GrpcDataSource.java +++ /dev/null @@ -1,480 +0,0 @@ -package org.ostelco.ocsgw.data.grpc; - -import com.google.auth.oauth2.ServiceAccountJwtAccessCredentials; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import io.grpc.StatusRuntimeException; -import io.grpc.auth.MoreCallCredentials; -import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; -import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; -import io.grpc.stub.StreamObserver; -import org.jdiameter.api.IllegalDiameterStateException; -import org.jdiameter.api.InternalException; -import org.jdiameter.api.OverloadException; -import org.jdiameter.api.RouteException; -import org.jdiameter.api.cca.ServerCCASession; -import org.ostelco.diameter.CreditControlContext; -import org.ostelco.diameter.model.CreditControlAnswer; -import org.ostelco.diameter.model.CreditControlRequest; -import org.ostelco.diameter.model.FinalUnitAction; -import org.ostelco.diameter.model.FinalUnitIndication; -import org.ostelco.diameter.model.MultipleServiceCreditControl; -import org.ostelco.diameter.model.RedirectAddressType; -import org.ostelco.diameter.model.RedirectServer; -import org.ostelco.diameter.model.SessionContext; -import org.ostelco.ocs.api.*; -import org.ostelco.ocsgw.OcsServer; -import org.ostelco.ocsgw.data.DataSource; -import org.ostelco.prime.metrics.api.OcsgwAnalyticsReport; -import org.ostelco.prime.metrics.api.User; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.*; -import java.util.concurrent.Callable; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -import static org.ostelco.diameter.model.RequestType.EVENT_REQUEST; -import static org.ostelco.diameter.model.RequestType.INITIAL_REQUEST; -import static org.ostelco.diameter.model.RequestType.TERMINATION_REQUEST; -import static org.ostelco.diameter.model.RequestType.UPDATE_REQUEST; - -/** - * Uses gRPC to fetch data remotely - */ -public class GrpcDataSource implements DataSource { - - private static final Logger LOG = LoggerFactory.getLogger(GrpcDataSource.class); - - private static final int KEEP_ALIVE_TIMEOUT_IN_MINUTES = 1; - - private static final int KEEP_ALIVE_TIME_IN_SECONDS = 50; - - private final OcsServiceGrpc.OcsServiceStub ocsServiceStub; - - private final Set blocked = new HashSet<>(); - - private StreamObserver creditControlRequest; - - private OcsgwMetrics ocsgwAnalytics; - - private ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); - - private ScheduledFuture keepAliveFuture = null; - - private ScheduledFuture initActivateFuture = null; - - private ScheduledFuture initCCRFuture = null; - - private static final int MAX_ENTRIES = 50000; - private final LinkedHashMap ccrMap = new LinkedHashMap(MAX_ENTRIES, .75F) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_ENTRIES; - } - }; - - private final LinkedHashMap sessionIdMap = new LinkedHashMap(MAX_ENTRIES, .75F) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_ENTRIES; - } - }; - - - private abstract class CreditControlRequestObserver implements StreamObserver { - public final void onError(Throwable t) { - LOG.error("CreditControlRequestObserver error", t); - if (t instanceof StatusRuntimeException) { - reconnectCreditControlRequest(); - } - } - - public final void onCompleted() { - // Nothing to do here - } - } - - private abstract class ActivateObserver implements StreamObserver { - public final void onError(Throwable t) { - LOG.error("ActivateObserver error", t); - if (t instanceof StatusRuntimeException) { - reconnectActivate(); - } - } - - public final void onCompleted() { - // Nothing to do here - } - } - - private void reconnectActivate() { - LOG.info("reconnectActivate called"); - - if (initActivateFuture != null) { - initActivateFuture.cancel(true); - } - - LOG.info("Schedule new Callable initActivate"); - initActivateFuture = executorService.schedule((Callable) () -> { - LOG.info("Calling initActivate"); - initActivate(); - return "Called!"; - }, - 5, - TimeUnit.SECONDS); - } - - private void reconnectCcrKeepAlive() { - LOG.info("reconnectCreditControlRequest called"); - if (keepAliveFuture != null) { - keepAliveFuture.cancel(true); - } - - initKeepAlive(); - } - - - private void reconnectCreditControlRequest() { - LOG.info("reconnectCreditControlRequest called"); - - if (initCCRFuture != null) { - initCCRFuture.cancel(true); - } - - LOG.info("Schedule new Callable initCreditControlRequest"); - initCCRFuture = executorService.schedule((Callable) () -> { - reconnectCcrKeepAlive(); - LOG.info("Calling initCreditControlRequest"); - initCreditControlRequest(); - return "Called!"; - }, - 5, - TimeUnit.SECONDS); - } - - /** - * Generate a new instande that connects to an endpoint, and - * optionally also encrypts the connection. - * - * @param ocsServerHostname The gRPC endpoint to connect the client to. - * @throws IOException - */ - public GrpcDataSource(final String ocsServerHostname, final String metricsServerHostname) throws IOException { - - LOG.info("Created GrpcDataSource"); - LOG.info("ocsServerHostname : {}", ocsServerHostname); - LOG.info("metricsServerHostname : {}", metricsServerHostname); - // Set up a channel to be used to communicate as an OCS instance, - // to a gRPC instance. - - // Initialize the stub that will be used to actually - // communicate from the client emulating being the OCS. - final NettyChannelBuilder nettyChannelBuilder = NettyChannelBuilder - .forTarget(ocsServerHostname) - .keepAliveWithoutCalls(true) - .keepAliveTimeout(KEEP_ALIVE_TIMEOUT_IN_MINUTES, TimeUnit.MINUTES) - .keepAliveTime(KEEP_ALIVE_TIME_IN_SECONDS, TimeUnit.SECONDS); - - final ManagedChannelBuilder channelBuilder = - Files.exists(Paths.get("/config/ocs.crt")) - ? nettyChannelBuilder.sslContext(GrpcSslContexts.forClient().trustManager(new File("/config/ocs.crt")).build()) - : nettyChannelBuilder; - - final String serviceAccountFile = System.getenv("GOOGLE_APPLICATION_CREDENTIALS"); - final ServiceAccountJwtAccessCredentials credentials = - ServiceAccountJwtAccessCredentials.fromStream(new FileInputStream(serviceAccountFile)); - final ManagedChannel channel = channelBuilder - .useTransportSecurity() - .build(); - ocsServiceStub = OcsServiceGrpc.newStub(channel) - .withCallCredentials(MoreCallCredentials.from(credentials)); - - ocsgwAnalytics = new OcsgwMetrics(metricsServerHostname, credentials); - } - - @Override - public void init() { - - initCreditControlRequest(); - - initActivate(); - - initKeepAlive(); - - ocsgwAnalytics.initAnalyticsRequest(); - } - - private void initCreditControlRequest() { - creditControlRequest = ocsServiceStub.creditControlRequest( - new CreditControlRequestObserver() { - public void onNext(CreditControlAnswerInfo answer) { - handleGrpcCcrAnswer(answer); - } - }); - } - - - private void handleGrpcCcrAnswer(CreditControlAnswerInfo answer) { - try { - LOG.info("[<<] CreditControlAnswer for {}", answer.getMsisdn()); - final CreditControlContext ccrContext = ccrMap.remove(answer.getRequestId()); - if (ccrContext != null) { - final ServerCCASession session = OcsServer.getInstance().getStack().getSession(ccrContext.getSessionId(), ServerCCASession.class); - if (session != null && session.isValid()) { - removeFromSessionMap(ccrContext); - updateBlockedList(answer, ccrContext.getCreditControlRequest()); - if (!ccrContext.getSkipAnswer()) { - CreditControlAnswer cca = createCreditControlAnswer(answer); - try { - session.sendCreditControlAnswer(ccrContext.createCCA(cca)); - } catch (InternalException | IllegalDiameterStateException | RouteException | OverloadException e) { - LOG.error("Failed to send Credit-Control-Answer", e); - } - } - } else { - LOG.warn("No stored CCR or Session for {}", answer.getRequestId()); - } - } else { - LOG.warn("Missing CreditControlContext for req id {}", answer.getRequestId()); - } - } catch (Exception e) { - LOG.error("Credit-Control-Request failed ", e); - } - } - - private void addToSessionMap(CreditControlContext creditControlContext) { - try { - SessionContext sessionContext = new SessionContext(creditControlContext.getSessionId(), - creditControlContext.getCreditControlRequest().getOriginHost(), - creditControlContext.getCreditControlRequest().getOriginRealm(), - creditControlContext.getCreditControlRequest().getServiceInformation().get(0).getPsInformation().get(0).getCalledStationId(), - creditControlContext.getCreditControlRequest().getServiceInformation().get(0).getPsInformation().get(0).getSgsnMccMnc()); - if (sessionIdMap.put(creditControlContext.getCreditControlRequest().getMsisdn(), sessionContext) == null) { - updateAnalytics(); - } - } catch (Exception e) { - LOG.error("Failed to update session map", e); - } - } - - private void removeFromSessionMap(CreditControlContext creditControlContext) { - if (getRequestType(creditControlContext) == CreditControlRequestType.TERMINATION_REQUEST) { - sessionIdMap.remove(creditControlContext.getCreditControlRequest().getMsisdn()); - updateAnalytics(); - } - } - - private void updateAnalytics() { - LOG.info("Number of active sessions is {}", sessionIdMap.size()); - - OcsgwAnalyticsReport.Builder builder = OcsgwAnalyticsReport.newBuilder().setActiveSessions(sessionIdMap.size()); - builder.setKeepAlive(false); - sessionIdMap.forEach((msisdn, sessionContext) -> { - builder.addUsers(User.newBuilder().setApn(sessionContext.getApn()).setMccMnc(sessionContext.getMccMnc()).setMsisdn(msisdn).build()); - }); - ocsgwAnalytics.sendAnalytics(builder.build()); - } - - private void initActivate() { - ActivateRequest dummyActivate = ActivateRequest.newBuilder().build(); - ocsServiceStub.activate(dummyActivate, new ActivateObserver() { - @Override - public void onNext(ActivateResponse activateResponse) { - LOG.info("Active user {}", activateResponse.getMsisdn()); - if (sessionIdMap.containsKey(activateResponse.getMsisdn())) { - final SessionContext sessionContext = sessionIdMap.get(activateResponse.getMsisdn()); - OcsServer.getInstance().sendReAuthRequest(sessionContext); - } else { - LOG.info("No session context stored for msisdn : {}", activateResponse.getMsisdn()); - } - } - }); - } - - private void initKeepAlive() { - // this is used to keep connection alive - keepAliveFuture = executorService.scheduleWithFixedDelay(() -> { - final CreditControlRequestInfo ccr = CreditControlRequestInfo.newBuilder() - .setType(CreditControlRequestType.NONE) - .build(); - creditControlRequest.onNext(ccr); - }, - 15, - 50, - TimeUnit.SECONDS); - } - - private void updateBlockedList(CreditControlAnswerInfo answer, CreditControlRequest request) { - // This suffers from the fact that one Credit-Control-Request can have multiple MSCC - for (org.ostelco.ocs.api.MultipleServiceCreditControl msccAnswer : answer.getMsccList()) { - for (MultipleServiceCreditControl msccRequest : request.getMultipleServiceCreditControls()) { - if ((msccAnswer.getServiceIdentifier() == msccRequest.getServiceIdentifier()) && (msccAnswer.getRatingGroup() == msccRequest.getRatingGroup())) { - updateBlockedList(msccAnswer, msccRequest, answer.getMsisdn()); - return; - } - } - } - } - - @Override - public void handleRequest(final CreditControlContext context) { - ccrMap.put(context.getSessionId(), context); - addToSessionMap(context); - LOG.info("[>>] creditControlRequest for {}", context.getCreditControlRequest().getMsisdn()); - - if (creditControlRequest != null) { - try { - CreditControlRequestInfo.Builder builder = CreditControlRequestInfo - .newBuilder() - .setType(getRequestType(context)); - - for (MultipleServiceCreditControl mscc : context.getCreditControlRequest().getMultipleServiceCreditControls()) { - - org.ostelco.ocs.api.MultipleServiceCreditControl.Builder protoMscc = org.ostelco.ocs.api.MultipleServiceCreditControl.newBuilder(); - - if (!mscc.getRequested().isEmpty()) { - - org.ostelco.diameter.model.ServiceUnit requested = mscc.getRequested().get(0); - - protoMscc.setRequested(ServiceUnit.newBuilder() - .setInputOctets(0L) - .setOutputOctetes(0L) - .setTotalOctets(requested.getTotal()) - .build()); - } - - - org.ostelco.diameter.model.ServiceUnit used = mscc.getUsed(); - - protoMscc.setUsed(ServiceUnit.newBuilder() - .setInputOctets(used.getInput()) - .setOutputOctetes(used.getOutput()) - .setTotalOctets(used.getTotal()) - .build()); - - protoMscc.setRatingGroup(mscc.getRatingGroup()); - protoMscc.setServiceIdentifier(mscc.getServiceIdentifier()); - - if (mscc.getReportingReason() != null) { - protoMscc.setReportingReasonValue(mscc.getReportingReason().ordinal()); - } else { - protoMscc.setReportingReasonValue(ReportingReason.UNRECOGNIZED.ordinal()); - } - builder.addMscc(protoMscc.build()); - } - - builder.setRequestId(context.getSessionId()) - .setMsisdn(context.getCreditControlRequest().getMsisdn()) - .setImsi(context.getCreditControlRequest().getImsi()); - - if (!context.getCreditControlRequest().getServiceInformation().isEmpty()) { - final org.ostelco.diameter.model.PsInformation psInformation - = context.getCreditControlRequest().getServiceInformation().get(0).getPsInformation().get(0); - - if (psInformation != null - && psInformation.getCalledStationId() != null - && psInformation.getSgsnMccMnc() != null) { - - builder.setServiceInformation( - ServiceInfo.newBuilder() - .setPsInformation(PsInformation.newBuilder() - .setCalledStationId(psInformation.getCalledStationId()) - .setSgsnMccMnc(psInformation.getSgsnMccMnc()) - .build()).build()); - } - } - creditControlRequest.onNext(builder.build()); - - } catch (Exception e) { - LOG.error("What just happened", e); - } - } else { - LOG.warn("[!!] creditControlRequest is null"); - } - } - - private CreditControlRequestType getRequestType(CreditControlContext context) { - CreditControlRequestType type = CreditControlRequestType.NONE; - switch (context.getOriginalCreditControlRequest().getRequestTypeAVPValue()) { - case INITIAL_REQUEST: - type = CreditControlRequestType.INITIAL_REQUEST; - break; - case UPDATE_REQUEST: - type = CreditControlRequestType.UPDATE_REQUEST; - break; - case TERMINATION_REQUEST: - type = CreditControlRequestType.TERMINATION_REQUEST; - break; - case EVENT_REQUEST: - type = CreditControlRequestType.EVENT_REQUEST; - break; - default: - LOG.warn("Unknown request type"); - break; - } - return type; - } - - private CreditControlAnswer createCreditControlAnswer(CreditControlAnswerInfo response) { - if (response == null) { - LOG.error("Empty CreditControlAnswerInfo received"); - return new CreditControlAnswer(new ArrayList<>()); - } - - final LinkedList multipleServiceCreditControls = new LinkedList<>(); - for (org.ostelco.ocs.api.MultipleServiceCreditControl mscc : response.getMsccList()) { - multipleServiceCreditControls.add(convertMSCC(mscc)); - } - return new CreditControlAnswer(multipleServiceCreditControls); - } - - private void updateBlockedList(org.ostelco.ocs.api.MultipleServiceCreditControl msccAnswer, MultipleServiceCreditControl msccRequest, String msisdn) { - if (!msccRequest.getRequested().isEmpty()) { - if (msccAnswer.getGranted().getTotalOctets() < msccRequest.getRequested().get(0).getTotal()) { - blocked.add(msisdn); - } else { - blocked.remove(msisdn); - } - } - } - - private MultipleServiceCreditControl convertMSCC(org.ostelco.ocs.api.MultipleServiceCreditControl msccGRPC) { - return new MultipleServiceCreditControl( - msccGRPC.getRatingGroup(), - (int) msccGRPC.getServiceIdentifier(), - Collections.singletonList(new org.ostelco.diameter.model.ServiceUnit()), - new org.ostelco.diameter.model.ServiceUnit(), - new org.ostelco.diameter.model.ServiceUnit(msccGRPC.getGranted().getTotalOctets(), 0, 0), - msccGRPC.getValidityTime(), - convertFinalUnitIndication(msccGRPC.getFinalUnitIndication())); - } - - private FinalUnitIndication convertFinalUnitIndication(org.ostelco.ocs.api.FinalUnitIndication fuiGrpc) { - if (!fuiGrpc.getIsSet()) { - return null; - } - return new FinalUnitIndication( - FinalUnitAction.values()[fuiGrpc.getFinalUnitAction().getNumber()], - fuiGrpc.getRestrictionFilterRuleList(), - fuiGrpc.getFilterIdList(), - new RedirectServer( - RedirectAddressType.values()[fuiGrpc.getRedirectServer().getRedirectAddressType().getNumber()], - fuiGrpc.getRedirectServer().getRedirectServerAddress() - ) - ); - } - - @Override - public boolean isBlocked(final String msisdn) { - return blocked.contains(msisdn); - } -} diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/data/grpc/OcsgwMetrics.java b/ocsgw/src/main/java/org/ostelco/ocsgw/data/grpc/OcsgwMetrics.java deleted file mode 100644 index 91c1d05a5..000000000 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/data/grpc/OcsgwMetrics.java +++ /dev/null @@ -1,154 +0,0 @@ -package org.ostelco.ocsgw.data.grpc; - -import com.google.auth.oauth2.ServiceAccountJwtAccessCredentials; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import io.grpc.StatusRuntimeException; -import io.grpc.auth.MoreCallCredentials; -import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; -import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; -import io.grpc.stub.StreamObserver; -import org.ostelco.prime.metrics.api.OcsgwAnalyticsReply; -import org.ostelco.prime.metrics.api.OcsgwAnalyticsReport; -import org.ostelco.prime.metrics.api.OcsgwAnalyticsServiceGrpc; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.net.ssl.SSLException; -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.concurrent.Callable; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -class OcsgwMetrics { - - private static final Logger LOG = LoggerFactory.getLogger(OcsgwMetrics.class); - - private static final int KEEP_ALIVE_TIMEOUT_IN_MINUTES = 1; - - private static final int KEEP_ALIVE_TIME_IN_SECONDS = 50; - - private OcsgwAnalyticsServiceGrpc.OcsgwAnalyticsServiceStub ocsgwAnalyticsServiceStub; - - private ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); - - private StreamObserver ocsgwAnalyticsReport; - - private ScheduledFuture initAnalyticsFuture = null; - - private ScheduledFuture keepAliveFuture = null; - - private ScheduledFuture autoReportAnalyticsFuture = null; - - private OcsgwAnalyticsReport lastActiveSessions = null; - - OcsgwMetrics(String metricsServerHostname, ServiceAccountJwtAccessCredentials credentials) { - - try { - final NettyChannelBuilder nettyChannelBuilder = NettyChannelBuilder - .forTarget(metricsServerHostname) - .keepAliveWithoutCalls(true) - .keepAliveTimeout(KEEP_ALIVE_TIMEOUT_IN_MINUTES, TimeUnit.MINUTES) - .keepAliveTime(KEEP_ALIVE_TIME_IN_SECONDS, TimeUnit.SECONDS); - - final ManagedChannelBuilder channelBuilder = Files.exists(Paths.get("/config/metrics.crt")) - ? nettyChannelBuilder.sslContext(GrpcSslContexts.forClient().trustManager(new File("/config/metrics.crt")).build()) - : nettyChannelBuilder; - - final ManagedChannel channel = channelBuilder - .useTransportSecurity() - .build(); - if (credentials != null) { - ocsgwAnalyticsServiceStub = OcsgwAnalyticsServiceGrpc.newStub(channel) - .withCallCredentials(MoreCallCredentials.from(credentials)); - } else { - ocsgwAnalyticsServiceStub = OcsgwAnalyticsServiceGrpc.newStub(channel); - } - } catch (SSLException e) { - LOG.warn("Failed to setup OcsMetrics", e); - } - } - - private abstract class AnalyticsRequestObserver implements StreamObserver { - public final void onError(Throwable t) { - LOG.error("AnalyticsRequestObserver error", t); - if (t instanceof StatusRuntimeException) { - reconnectAnalyticsReport(); - } - } - - public final void onCompleted() { - // Nothing to do here - } - } - - private void reconnectKeepAlive() { - LOG.info("reconnectKeepAlive called"); - if (keepAliveFuture != null) { - keepAliveFuture.cancel(true); - } - } - - private void reconnectAnalyticsReport() { - LOG.info("reconnectAnalyticsReport called"); - - if (initAnalyticsFuture != null) { - initAnalyticsFuture.cancel(true); - } - - LOG.info("Schedule new Callable initAnalyticsRequest"); - initAnalyticsFuture = executorService.schedule((Callable) () -> { - reconnectKeepAlive(); - LOG.info("Calling initAnalyticsRequest"); - initAnalyticsRequest(); - sendAnalytics(lastActiveSessions); - return "Called!"; - }, - 5, - TimeUnit.SECONDS); - } - - private void initAutoReportAnalyticsReport() { - autoReportAnalyticsFuture = executorService.scheduleAtFixedRate((Runnable) () -> { - sendAnalytics(lastActiveSessions); - }, - 30, - 30, - TimeUnit.MINUTES); - } - - void initAnalyticsRequest() { - ocsgwAnalyticsReport = ocsgwAnalyticsServiceStub.ocsgwAnalyticsEvent( - new AnalyticsRequestObserver() { - - @Override - public void onNext(OcsgwAnalyticsReply value) { - // Ignore reply from Prime - } - } - ); - initKeepAlive(); - initAutoReportAnalyticsReport(); - } - - private void initKeepAlive() { - // this is used to keep connection alive - keepAliveFuture = executorService.scheduleWithFixedDelay(() -> { - sendAnalytics(OcsgwAnalyticsReport.newBuilder().setKeepAlive(true).build()); - }, - 15, - 50, - TimeUnit.SECONDS); - } - - void sendAnalytics(OcsgwAnalyticsReport report) { - if (report != null) { - ocsgwAnalyticsReport.onNext(report); - lastActiveSessions = report; - } - } -} \ No newline at end of file diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/data/DataSource.java b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/DataSource.java similarity index 88% rename from ocsgw/src/main/java/org/ostelco/ocsgw/data/DataSource.java rename to ocsgw/src/main/java/org/ostelco/ocsgw/datasource/DataSource.java index ce93aa260..173a2077a 100644 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/data/DataSource.java +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/DataSource.java @@ -1,4 +1,4 @@ -package org.ostelco.ocsgw.data; +package org.ostelco.ocsgw.datasource; import org.ostelco.diameter.CreditControlContext; @@ -10,7 +10,7 @@ public interface DataSource { /** - * Initiates the data source + * Initiates the datasource */ void init(); diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/DataSourceTypes.kt b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/DataSourceTypes.kt new file mode 100644 index 000000000..0acbe477a --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/DataSourceTypes.kt @@ -0,0 +1,13 @@ +package org.ostelco.ocsgw.datasource + +enum class DataSourceType { + Local, + gRPC, + PubSub, + Proxy +} + +enum class SecondaryDataSourceType { + gRPC, + PubSub +} \ No newline at end of file diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/data/local/LocalDataSource.java b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/local/LocalDataSource.java similarity index 78% rename from ocsgw/src/main/java/org/ostelco/ocsgw/data/local/LocalDataSource.java rename to ocsgw/src/main/java/org/ostelco/ocsgw/datasource/local/LocalDataSource.java index 3b45e09f7..2f7791411 100644 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/data/local/LocalDataSource.java +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/local/LocalDataSource.java @@ -1,4 +1,4 @@ -package org.ostelco.ocsgw.data.local; +package org.ostelco.ocsgw.datasource.local; import org.jdiameter.api.IllegalDiameterStateException; import org.jdiameter.api.InternalException; @@ -6,16 +6,10 @@ import org.jdiameter.api.RouteException; import org.jdiameter.api.cca.ServerCCASession; import org.ostelco.diameter.CreditControlContext; -import org.ostelco.diameter.model.CreditControlAnswer; -import org.ostelco.diameter.model.FinalUnitAction; -import org.ostelco.diameter.model.FinalUnitIndication; -import org.ostelco.diameter.model.MultipleServiceCreditControl; -import org.ostelco.diameter.model.RedirectAddressType; -import org.ostelco.diameter.model.RedirectServer; -import org.ostelco.diameter.model.ServiceUnit; +import org.ostelco.diameter.model.*; import org.ostelco.ocs.api.CreditControlRequestType; import org.ostelco.ocsgw.OcsServer; -import org.ostelco.ocsgw.data.DataSource; +import org.ostelco.ocsgw.datasource.DataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,12 +34,13 @@ public void init() { @Override public void handleRequest(CreditControlContext context) { CreditControlAnswer answer = createCreditControlAnswer(context); - LOG.info("Sending Credit-Control-Answer"); + LOG.info("Got Credit-Control-Request [{}]", context.getCreditControlRequest().getMsisdn()); try { - final ServerCCASession session = OcsServer.getInstance().getStack().getSession(context.getSessionId(), ServerCCASession.class); + final ServerCCASession session = OcsServer.INSTANCE.getStack().getSession(context.getSessionId(), ServerCCASession.class); session.sendCreditControlAnswer(context.createCCA(answer)); - } catch (InternalException | IllegalDiameterStateException | RouteException | OverloadException e) { - LOG.error("Failed to send Credit-Control-Answer. SessionId : {}", context.getSessionId()); + LOG.info("Sent Credit-Control-Answer [{}]", context.getCreditControlRequest().getMsisdn()); + } catch (InternalException | IllegalDiameterStateException | RouteException | OverloadException | NullPointerException e) { + LOG.error("Failed to send Credit-Control-Answer. SessionId : {}", context.getSessionId(), e); } } @@ -88,12 +83,15 @@ private CreditControlAnswer createCreditControlAnswer(CreditControlContext conte new ServiceUnit(mscc.getUsed().getTotal(), mscc.getUsed().getInput(), mscc.getUsed().getOutput()), granted, mscc.getValidityTime(), - finalUnitIndication); + 7200, + (long) (granted.getTotal() * 0.2), // 20% + finalUnitIndication, + ResultCode.DIAMETER_SUCCESS); newMultipleServiceCreditControls.add(newMscc); } - return new CreditControlAnswer(newMultipleServiceCreditControls); + return new CreditControlAnswer(ResultCode.DIAMETER_SUCCESS, newMultipleServiceCreditControls); } public boolean isBlocked(final String msisdn) { diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/GrpcDataSource.java b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/GrpcDataSource.java new file mode 100644 index 000000000..7fe7194c4 --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/GrpcDataSource.java @@ -0,0 +1,288 @@ +package org.ostelco.ocsgw.datasource.protobuf; + +import com.google.auth.oauth2.ServiceAccountJwtAccessCredentials; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.StatusRuntimeException; +import io.grpc.auth.MoreCallCredentials; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.stub.StreamObserver; +import org.ostelco.diameter.CreditControlContext; +import org.ostelco.ocs.api.ActivateRequest; +import org.ostelco.ocs.api.ActivateResponse; +import org.ostelco.ocs.api.CreditControlAnswerInfo; +import org.ostelco.ocs.api.CreditControlRequestInfo; +import org.ostelco.ocs.api.CreditControlRequestType; +import org.ostelco.ocs.api.OcsServiceGrpc; +import org.ostelco.ocsgw.datasource.DataSource; +import org.ostelco.ocsgw.metrics.OcsgwMetrics; +import org.ostelco.ocsgw.utils.EventConsumer; +import org.ostelco.ocsgw.utils.EventProducer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLException; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + + +/** + * Uses gRPC to fetch data remotely + */ +public class GrpcDataSource implements DataSource { + + private static final Logger LOG = LoggerFactory.getLogger(GrpcDataSource.class); + + private OcsServiceGrpc.OcsServiceStub ocsServiceStub; + + private StreamObserver creditControlRequestStream; + + private String ocsServerHostname; + + private ManagedChannel grpcChannel; + + private ServiceAccountJwtAccessCredentials jwtAccessCredentials; + + private OcsgwMetrics ocsgwAnalytics; + + private ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + + private ScheduledFuture reconnectStreamFuture = null; + + private final ConcurrentLinkedQueue requestQueue = new ConcurrentLinkedQueue<>(); + + private final EventProducer producer; + + private Thread consumerThread; + + private ProtobufDataSource protobufDataSource; + + /** + * Generate a new instance that connects to an endpoint, and + * optionally also encrypts the connection. + * + * @param ocsServerHostname The gRPC endpoint to connect the client to. + * @throws IOException + */ + public GrpcDataSource( + final ProtobufDataSource protobufDataSource, + final String ocsServerHostname, + final String metricsServerHostname) throws IOException { + + this.protobufDataSource = protobufDataSource; + + this.ocsServerHostname = ocsServerHostname; + + LOG.info("Created GrpcDataSource"); + LOG.info("ocsServerHostname : {}", ocsServerHostname); + LOG.info("metricsServerHostname : {}", metricsServerHostname); + + // Not using the standard GOOGLE_APPLICATION_CREDENTIALS for this + // as we need to download the file using container credentials in + // OcsApplication. + final String serviceAccountFile = "/config/" + System.getenv("SERVICE_FILE"); + jwtAccessCredentials = ServiceAccountJwtAccessCredentials.fromStream(new FileInputStream(serviceAccountFile)); + + ocsgwAnalytics = new OcsgwMetrics(metricsServerHostname, jwtAccessCredentials, protobufDataSource); + producer = new EventProducer<>(requestQueue); + } + + @Override + public void init() { + + setupChannel(); + initCreditControlRequestStream(); + initActivateStream(); + initKeepAlive(); + ocsgwAnalytics.initAnalyticsRequestStream(); + + setupEventConsumer(); + } + + private void setupEventConsumer() { + + // ToDo : Is this enough to know the thread stopped? + if (consumerThread != null) { + consumerThread.interrupt(); + } + + EventConsumer requestInfoConsumer = new EventConsumer<>(requestQueue, creditControlRequestStream); + consumerThread = new Thread(requestInfoConsumer); + consumerThread.start(); + } + + private void setupChannel() { + + + ManagedChannelBuilder channelBuilder; + + // Set up a channel to be used to communicate as an OCS instance, + // to a gRPC instance. + + final boolean disableTls = Boolean.valueOf(System.getenv("DISABLE_TLS")); + + try { + if (disableTls) { + channelBuilder = ManagedChannelBuilder + .forTarget(ocsServerHostname) + .usePlaintext(); + } else { + final NettyChannelBuilder nettyChannelBuilder = NettyChannelBuilder + .forTarget(ocsServerHostname); + + + channelBuilder = Files.exists(Paths.get("/cert/ocs.crt")) + ? nettyChannelBuilder.sslContext( + GrpcSslContexts.forClient().trustManager(new File("/cert/ocs.crt")).build()) + .useTransportSecurity() + : nettyChannelBuilder.useTransportSecurity(); + } + + if (grpcChannel != null) { + + grpcChannel.shutdownNow(); + try { + boolean isShutdown = grpcChannel.awaitTermination(3, TimeUnit.SECONDS); + LOG.info("grpcChannel is shutdown : " + isShutdown); + } catch (InterruptedException e) { + LOG.info("Error shutting down gRPC channel"); + } + } + + grpcChannel = channelBuilder + .keepAliveWithoutCalls(true) +// .keepAliveTimeout(1, TimeUnit.MINUTES) +// .keepAliveTime(20, TimeUnit.MINUTES) + .build(); + + ocsServiceStub = OcsServiceGrpc.newStub(grpcChannel) + .withCallCredentials(MoreCallCredentials.from(jwtAccessCredentials)); + + } catch (SSLException e) { + LOG.warn("Failed to setup gRPC channel", e); + } + } + + + /** + * Init the gRPC channel that will be used to send/receive + * diameter messages to the OCS module in Prime. + */ + private void initCreditControlRequestStream() { + creditControlRequestStream = ocsServiceStub.creditControlRequest( + new StreamObserver() { + public void onNext(CreditControlAnswerInfo answer) { + protobufDataSource.handleCcrAnswer(answer); + } + + @Override + public void onError(Throwable t) { + LOG.error("CreditControlRequestStream error", t); + if (t instanceof StatusRuntimeException) { + reconnectStreams(); + } + } + + @Override + public void onCompleted() { + // Nothing to do here + } + }); + } + + /** + * Init the gRPC channel that will be used to get activation requests from the + * OCS. These requests are send when we need to reactivate a diameter session. For + * example on a topup event. + */ + private void initActivateStream() { + ActivateRequest dummyActivate = ActivateRequest.newBuilder().build(); + ocsServiceStub.activate(dummyActivate, new StreamObserver() { + @Override + public void onNext(ActivateResponse activateResponse) { + protobufDataSource.handleActivateResponse(activateResponse); + } + + @Override + public void onError(Throwable t) { + LOG.error("ActivateObserver error", t); + if (t instanceof StatusRuntimeException) { + reconnectStreams(); + } + } + + @Override + public void onCompleted() { + // Nothing to do here + } + }); + } + + /** + * The keep alive messages are sent on the creditControlRequestStream + * to force it to stay open avoiding reconnects on the gRPC channel. + */ + private void initKeepAlive() { + // this is used to keep connection alive + executorService.scheduleWithFixedDelay(() -> { + final CreditControlRequestInfo ccr = CreditControlRequestInfo.newBuilder() + .setType(CreditControlRequestType.NONE) + .build(); + producer.queueEvent(ccr); + }, + 10, + 5, + TimeUnit.SECONDS); + } + + + private void reconnectStreams() { + LOG.debug("reconnectStreams called"); + + if (!isReconnecting()) { + + reconnectStreamFuture = executorService.schedule((Callable) () -> { + LOG.debug("Reconnecting GRPC streams"); + setupChannel(); + initCreditControlRequestStream(); + setupEventConsumer(); + initActivateStream(); + return "Called!"; + }, + 5, + TimeUnit.SECONDS); + } + } + + private boolean isReconnecting() { + if (reconnectStreamFuture != null) { + return !reconnectStreamFuture.isDone(); + } + return false; + } + + @Override + public void handleRequest(final CreditControlContext context) { + + CreditControlRequestInfo creditControlRequestInfo = protobufDataSource.handleRequest(context, null); + + if (creditControlRequestInfo != null) { + producer.queueEvent(creditControlRequestInfo); + } + } + + @Override + public boolean isBlocked(final String msisdn) { + return protobufDataSource.isBlocked(msisdn); + } +} diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/ProtobufDataSource.kt b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/ProtobufDataSource.kt new file mode 100644 index 000000000..5611cee6e --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/ProtobufDataSource.kt @@ -0,0 +1,168 @@ +package org.ostelco.ocsgw.datasource.protobuf + +import org.jdiameter.api.IllegalDiameterStateException +import org.jdiameter.api.InternalException +import org.jdiameter.api.OverloadException +import org.jdiameter.api.RouteException +import org.jdiameter.api.cca.ServerCCASession +import org.ostelco.diameter.CreditControlContext +import org.ostelco.diameter.getLogger +import org.ostelco.diameter.model.CreditControlAnswer +import org.ostelco.diameter.model.CreditControlRequest +import org.ostelco.diameter.model.MultipleServiceCreditControl +import org.ostelco.diameter.model.ResultCode.DIAMETER_UNABLE_TO_COMPLY +import org.ostelco.diameter.model.SessionContext +import org.ostelco.ocs.api.ActivateResponse +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocs.api.CreditControlRequestInfo +import org.ostelco.ocs.api.CreditControlRequestType +import org.ostelco.ocsgw.OcsServer +import org.ostelco.ocsgw.converter.ProtobufToDiameterConverter +import org.ostelco.prime.metrics.api.OcsgwAnalyticsReport +import org.ostelco.prime.metrics.api.User +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +class ProtobufDataSource { + + private val logger by getLogger() + + private val blocked = Collections.newSetFromMap(ConcurrentHashMap()) + + private val ccrMap = ConcurrentHashMap() + + private val sessionIdMap = ConcurrentHashMap() + + fun handleRequest(context: CreditControlContext, topicId: String?): CreditControlRequestInfo? { + + logger.info("[>>] creditControlRequest for {}", context.creditControlRequest.msisdn) + + // FixMe: We should handle conversion errors + val creditControlRequestInfo = ProtobufToDiameterConverter.convertRequestToProtobuf(context, topicId) + if (creditControlRequestInfo != null) { + ccrMap[context.sessionId] = context + addToSessionMap(context) + } + return creditControlRequestInfo + } + + fun handleCcrAnswer(answer: CreditControlAnswerInfo) { + try { + logger.info("[<<] CreditControlAnswer for {}", answer.msisdn) + val ccrContext = ccrMap.remove(answer.requestId) + if (ccrContext != null) { + val session = OcsServer.stack?.getSession(ccrContext.sessionId, ServerCCASession::class.java) + if (session != null && session.isValid) { + removeFromSessionMap(ccrContext) + updateBlockedList(answer, ccrContext.creditControlRequest) + if (!ccrContext.skipAnswer) { + val cca = createCreditControlAnswer(answer) + try { + session.sendCreditControlAnswer(ccrContext.createCCA(cca)) + } catch (e: InternalException) { + logger.error("Failed to send Credit-Control-Answer", e) + } catch (e: IllegalDiameterStateException) { + logger.error("Failed to send Credit-Control-Answer", e) + } catch (e: RouteException) { + logger.error("Failed to send Credit-Control-Answer", e) + } catch (e: OverloadException) { + logger.error("Failed to send Credit-Control-Answer", e) + } + + } + } else { + logger.warn("No stored CCR or Session for {}", answer.requestId) + } + } else { + logger.warn("Missing CreditControlContext for req id {}", answer.requestId) + } + } catch (e: Exception) { + logger.error("Credit-Control-Request failed ", e) + } + } + + fun handleActivateResponse(activateResponse : ActivateResponse) { + + logger.info("Active user {}", activateResponse.msisdn) + + if (sessionIdMap.containsKey(activateResponse.msisdn)) { + val sessionContext = sessionIdMap[activateResponse.msisdn] + OcsServer.sendReAuthRequest(sessionContext) + } else { + logger.debug("No session context stored for msisdn : {}", activateResponse.msisdn) + } + } + + private fun addToSessionMap(creditControlContext: CreditControlContext) { + try { + val sessionContext = SessionContext(creditControlContext.sessionId, + creditControlContext.creditControlRequest.originHost, + creditControlContext.creditControlRequest.originRealm, + creditControlContext.creditControlRequest.serviceInformation[0].psInformation[0].calledStationId, + creditControlContext.creditControlRequest.serviceInformation[0].psInformation[0].sgsnMccMnc) + sessionIdMap[creditControlContext.creditControlRequest.msisdn] = sessionContext + } catch (e: Exception) { + logger.error("Failed to update session map", e) + } + + } + + private fun removeFromSessionMap(creditControlContext: CreditControlContext) { + if (ProtobufToDiameterConverter.getRequestType(creditControlContext) == CreditControlRequestType.TERMINATION_REQUEST) { + sessionIdMap.remove(creditControlContext.creditControlRequest.msisdn) + } + } + + fun getAnalyticsReport(): OcsgwAnalyticsReport { + val builder = OcsgwAnalyticsReport.newBuilder().setActiveSessions(sessionIdMap.size) + builder.keepAlive = false + sessionIdMap.forEach { msisdn, (_, _, _, apn, mccMnc) -> builder.addUsers(User.newBuilder().setApn(apn).setMccMnc(mccMnc).setMsisdn(msisdn).build()) } + return builder.build() + } + + /** + * A user will be blocked if one of the MSCC in the request could not be filled in the answer + */ + private fun updateBlockedList(answer: CreditControlAnswerInfo, request: CreditControlRequest) { + for (msccAnswer in answer.msccList) { + for (msccRequest in request.multipleServiceCreditControls) { + if (msccAnswer.serviceIdentifier == msccRequest.serviceIdentifier && msccAnswer.ratingGroup == msccRequest.ratingGroup) { + if (updateBlockedList(msccAnswer, msccRequest, answer.msisdn)) { + return + } + } + } + } + } + + private fun updateBlockedList(msccAnswer: org.ostelco.ocs.api.MultipleServiceCreditControl, msccRequest: MultipleServiceCreditControl, msisdn: String): Boolean { + if (!msccRequest.requested.isEmpty()) { + if (msccAnswer.granted.totalOctets < msccRequest.requested[0].total) { + blocked.add(msisdn) + return true + } else { + blocked.remove(msisdn) + } + } + return false + } + + private fun createCreditControlAnswer(response: CreditControlAnswerInfo?): CreditControlAnswer { + if (response == null) { + logger.error("Empty CreditControlAnswerInfo received") + return CreditControlAnswer(DIAMETER_UNABLE_TO_COMPLY, ArrayList()) + } + + val multipleServiceCreditControls = LinkedList() + for (mscc in response.msccList) { + multipleServiceCreditControls.add(ProtobufToDiameterConverter.convertMSCC(mscc)) + } + return CreditControlAnswer(ProtobufToDiameterConverter.convertResultCode(response.resultCode), multipleServiceCreditControls) + } + + + fun isBlocked(msisdn: String): Boolean { + return blocked.contains(msisdn) + } + +} \ No newline at end of file diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/PubSubDataSource.kt b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/PubSubDataSource.kt new file mode 100644 index 000000000..e55ddbfa3 --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/protobuf/PubSubDataSource.kt @@ -0,0 +1,167 @@ +package org.ostelco.ocsgw.datasource.protobuf + +import com.google.api.core.ApiFutureCallback +import com.google.api.core.ApiFutures +import com.google.api.gax.core.NoCredentialsProvider +import com.google.api.gax.grpc.GrpcTransportChannel +import com.google.api.gax.rpc.ApiException +import com.google.api.gax.rpc.FixedTransportChannelProvider +import com.google.api.gax.rpc.TransportChannelProvider +import com.google.cloud.pubsub.v1.AckReplyConsumer +import com.google.cloud.pubsub.v1.MessageReceiver +import com.google.cloud.pubsub.v1.Publisher +import com.google.cloud.pubsub.v1.Subscriber +import com.google.protobuf.ByteString +import com.google.pubsub.v1.ProjectSubscriptionName +import com.google.pubsub.v1.ProjectTopicName +import com.google.pubsub.v1.PubsubMessage +import io.grpc.ManagedChannelBuilder +import org.ostelco.diameter.CreditControlContext +import org.ostelco.diameter.getLogger +import org.ostelco.ocs.api.ActivateResponse +import org.ostelco.ocs.api.CreditControlAnswerInfo +import org.ostelco.ocsgw.datasource.DataSource +import java.util.* +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService + + +class PubSubDataSource( + private val protobufDataSource: ProtobufDataSource, + projectId: String, + ccrTopicId: String, + private val ccaTopicId: String, + ccaSubscriptionId: String, + activateSubscriptionId: String) : DataSource { + + private val logger by getLogger() + + private var singleThreadScheduledExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + + private var pubSubChannelProvider: TransportChannelProvider? = null + private var publisher: Publisher + + init { + + val strSocketAddress = System.getenv("PUBSUB_EMULATOR_HOST") + if (!strSocketAddress.isNullOrEmpty()) { + val channel = ManagedChannelBuilder.forTarget(strSocketAddress).usePlaintext().build() + // Create a publisher instance with default settings bound to the topic + pubSubChannelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)) + } + + // init publisher + logger.info("Setting up Publisher for topic: {}", ccrTopicId) + publisher = setupPublisherToTopic(projectId, ccrTopicId) + + // Instantiate an asynchronous message receiver + setupPubSubSubscriber(projectId, ccaSubscriptionId) { message, consumer -> + // handle incoming message, then ack/nack the received message + val ccaInfo = CreditControlAnswerInfo.parseFrom(message) + logger.info("[<<] CreditControlAnswer for {}", ccaInfo.msisdn) + protobufDataSource.handleCcrAnswer(ccaInfo) + consumer.ack() + } + + setupPubSubSubscriber(projectId, activateSubscriptionId) { message, consumer -> + // handle incoming message, then ack/nack the received message + protobufDataSource.handleActivateResponse( + ActivateResponse.parseFrom(message)) + consumer.ack() + } + } + + override fun init() { + + } + + override fun handleRequest(context: CreditControlContext) { + + logger.info("[>>] creditControlRequest for {}", context.creditControlRequest.msisdn) + + val creditControlRequestInfo = protobufDataSource.handleRequest(context, ccaTopicId) + + if (creditControlRequestInfo != null) { + val base64String = Base64.getEncoder().encodeToString( + creditControlRequestInfo.toByteArray()) + logger.debug("[>>] base64String: {}", base64String) + val byteString = ByteString.copyFromUtf8(base64String) + + if (!byteString.isValidUtf8) { + logger.warn("Could not convert creditControlRequestInfo to UTF-8") + return + } + val pubsubMessage = PubsubMessage.newBuilder() + .setMessageId(creditControlRequestInfo.requestId) + .setData(byteString) + .build() + + //schedule a message to be published, messages are automatically batched + val future = publisher.publish(pubsubMessage) + + // add an asynchronous callback to handle success / failure + ApiFutures.addCallback(future, object : ApiFutureCallback { + + override fun onFailure(throwable: Throwable) { + if (throwable is ApiException) { + // details on the API exception + logger.warn("Status code: {}", throwable.statusCode.code) + logger.warn("Retrying: {}", throwable.isRetryable) + } + logger.warn("Error sending CCR Request to PubSub") + } + + override fun onSuccess(messageId: String) { + // Once published, returns server-assigned message ids (unique within the topic) + logger.debug("Submitted message with request-id: {} successfully", messageId) + } + }, singleThreadScheduledExecutor) + } + } + + override fun isBlocked(msisdn: String): Boolean = protobufDataSource.isBlocked(msisdn) + + private fun setupPublisherToTopic(projectId: String, topicId: String): Publisher { + logger.info("Setting up Publisher for topic: {}", topicId) + val topicName = ProjectTopicName.of(projectId, topicId) + return pubSubChannelProvider + ?.let { channelProvider -> + Publisher.newBuilder(topicName) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider()) + .build() + } + ?: Publisher.newBuilder(topicName).build() + } + + private fun setupPubSubSubscriber(projectId: String, subscriptionId: String, handler: (ByteString, AckReplyConsumer) -> Unit) { + + // init subscriber + logger.info("Setting up Subscriber for subscription: {}", subscriptionId) + val subscriptionName = ProjectSubscriptionName.of(projectId, subscriptionId) + + val receiver = MessageReceiver { message, consumer -> + val base64String = message.data.toStringUtf8() + logger.debug("[<<] base64String: {}", base64String) + handler(ByteString.copyFrom(Base64.getDecoder().decode(base64String)), consumer) + } + + val subscriber: Subscriber? + try { + // Create a subscriber for "my-subscription-id" bound to the message receiver + subscriber = pubSubChannelProvider + ?.let {channelProvider -> + Subscriber.newBuilder(subscriptionName, receiver) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider()) + .build() + } + ?: Subscriber.newBuilder(subscriptionName, receiver) + .build() + subscriber?.startAsync()?.awaitRunning() + } finally { + // stop receiving messages + // subscriber?.stopAsync() + } + } +} \ No newline at end of file diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/data/proxy/ProxyDataSource.java b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/proxy/ProxyDataSource.java similarity index 90% rename from ocsgw/src/main/java/org/ostelco/ocsgw/data/proxy/ProxyDataSource.java rename to ocsgw/src/main/java/org/ostelco/ocsgw/datasource/proxy/ProxyDataSource.java index c7744df98..6db951190 100644 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/data/proxy/ProxyDataSource.java +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/datasource/proxy/ProxyDataSource.java @@ -1,9 +1,10 @@ -package org.ostelco.ocsgw.data.proxy; +package org.ostelco.ocsgw.datasource.proxy; -import org.ostelco.ocsgw.data.DataSource; -import org.ostelco.ocsgw.data.local.LocalDataSource; +import org.ostelco.ocsgw.datasource.DataSource; +import org.ostelco.ocsgw.datasource.local.LocalDataSource; import org.ostelco.diameter.CreditControlContext; import org.ostelco.ocs.api.CreditControlRequestType; + /** * Proxy DataSource is a combination of the Local DataSource and any other * DataSource. @@ -40,7 +41,7 @@ public void handleRequest(CreditControlContext context) { // For CCR-U we will send all requests to both Local and Secondary until the secondary has blocked the msisdn if (!secondary.isBlocked(context.getCreditControlRequest().getMsisdn())) { local.handleRequest(context); - // When local datasource will be responding with Answer, gRPC datasource should skip to send Answer to PGw. + // When local datasource will be responding with Answer, gRPC datasource should skip to send Answer to P-GW. context.setSkipAnswer(true); secondary.handleRequest(context); } else { diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/metrics/OcsgwMetrics.java b/ocsgw/src/main/java/org/ostelco/ocsgw/metrics/OcsgwMetrics.java new file mode 100644 index 000000000..7f59e74f2 --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/metrics/OcsgwMetrics.java @@ -0,0 +1,176 @@ +package org.ostelco.ocsgw.metrics; + +import com.google.auth.oauth2.ServiceAccountJwtAccessCredentials; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.StatusRuntimeException; +import io.grpc.auth.MoreCallCredentials; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.stub.StreamObserver; +import org.ostelco.ocsgw.datasource.protobuf.ProtobufDataSource; +import org.ostelco.prime.metrics.api.OcsgwAnalyticsReply; +import org.ostelco.prime.metrics.api.OcsgwAnalyticsReport; +import org.ostelco.prime.metrics.api.OcsgwAnalyticsServiceGrpc; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLException; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class OcsgwMetrics { + + private static final Logger LOG = LoggerFactory.getLogger(OcsgwMetrics.class); + + private static final int KEEP_ALIVE_TIMEOUT_IN_MINUTES = 1; + + private static final int KEEP_ALIVE_TIME_IN_MINUTES = 20; + + private OcsgwAnalyticsServiceGrpc.OcsgwAnalyticsServiceStub ocsgwAnalyticsServiceStub; + + private ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + + private ScheduledFuture initAnalyticsFuture = null; + + private ScheduledFuture sendAnalyticsFuture = null; + + private StreamObserver ocsgwAnalyticsReportStream; + + private ManagedChannel grpcChannel; + + private ServiceAccountJwtAccessCredentials credentials; + + private String metricsServerHostname; + + private ProtobufDataSource protobufDataSource; + + public OcsgwMetrics( + String metricsServerHostname, + ServiceAccountJwtAccessCredentials serviceAccountJwtAccessCredentials, + ProtobufDataSource protobufDataSource) { + + this.protobufDataSource = protobufDataSource; + credentials = serviceAccountJwtAccessCredentials; + this.metricsServerHostname = metricsServerHostname; + } + + private void setupChannel() { + + ManagedChannelBuilder channelBuilder; + + final boolean disableTls = Boolean.valueOf(System.getenv("DISABLE_TLS")); + + try { + if (disableTls) { + channelBuilder = ManagedChannelBuilder + .forTarget(metricsServerHostname) + .usePlaintext(); + } else { + final NettyChannelBuilder nettyChannelBuilder = NettyChannelBuilder + .forTarget(metricsServerHostname); + + + channelBuilder = Files.exists(Paths.get("/cert/metrics.crt")) + ? nettyChannelBuilder.sslContext( + GrpcSslContexts.forClient().trustManager(new File("/cert/metrics.crt")).build()) + .useTransportSecurity() + :nettyChannelBuilder.useTransportSecurity(); + + } + + if (grpcChannel != null) { + + grpcChannel.shutdownNow(); + try { + boolean isShutdown = grpcChannel.awaitTermination(3, TimeUnit.SECONDS); + LOG.info("grpcChannel is shutdown : " + isShutdown); + } catch (InterruptedException e) { + LOG.info("Error shutting down gRPC channel"); + } + } + + grpcChannel = channelBuilder + .keepAliveWithoutCalls(true) +// .keepAliveTimeout(KEEP_ALIVE_TIMEOUT_IN_MINUTES, TimeUnit.MINUTES) +// .keepAliveTime(KEEP_ALIVE_TIME_IN_MINUTES, TimeUnit.MINUTES) + .build(); + + ocsgwAnalyticsServiceStub = OcsgwAnalyticsServiceGrpc.newStub(grpcChannel) + .withCallCredentials(MoreCallCredentials.from(credentials));; + + } catch (SSLException e) { + LOG.warn("Failed to setup gRPC channel", e); + } + } + + public void initAnalyticsRequestStream() { + + setupChannel(); + + ocsgwAnalyticsReportStream = ocsgwAnalyticsServiceStub.ocsgwAnalyticsEvent( + new StreamObserver() { + + @Override + public void onNext(OcsgwAnalyticsReply value) { + // Ignore reply from Prime + } + + @Override + public void onError(Throwable t) { + LOG.error("AnalyticsRequestObserver error", t); + if (t instanceof StatusRuntimeException) { + reconnectAnalyticsReportStream(); + } + } + + @Override + public void onCompleted() { + // Nothing to do here + } + } + ); + initAutoReportAnalyticsReport(); + } + + + private void sendAnalyticsReport(OcsgwAnalyticsReport report) { + if (report != null) { + ocsgwAnalyticsReportStream.onNext(report); + } + } + + private void reconnectAnalyticsReportStream() { + LOG.debug("reconnectAnalyticsReportStream called"); + + if (initAnalyticsFuture != null) { + initAnalyticsFuture.cancel(true); + } + + LOG.debug("Schedule new Callable initAnalyticsRequest"); + initAnalyticsFuture = executorService.schedule((Callable) () -> { + initAnalyticsRequestStream(); + return "Called!"; + }, + 5, + TimeUnit.SECONDS); + } + + private void initAutoReportAnalyticsReport() { + + if (sendAnalyticsFuture == null) { + sendAnalyticsFuture = executorService.scheduleAtFixedRate(() -> { + sendAnalyticsReport(protobufDataSource.getAnalyticsReport()); + }, + 0, + 5, + TimeUnit.SECONDS); + } + } +} \ No newline at end of file diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/utils/AppConfig.java b/ocsgw/src/main/java/org/ostelco/ocsgw/utils/AppConfig.java index 6695468e5..9482d2181 100644 --- a/ocsgw/src/main/java/org/ostelco/ocsgw/utils/AppConfig.java +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/utils/AppConfig.java @@ -1,5 +1,8 @@ package org.ostelco.ocsgw.utils; +import org.ostelco.ocsgw.datasource.DataSourceType; +import org.ostelco.ocsgw.datasource.SecondaryDataSourceType; + import java.io.IOException; import java.io.InputStream; import java.util.Properties; @@ -15,25 +18,83 @@ public AppConfig() throws IOException { iStream.close(); } - public String getDataStoreType () { - return prop.getProperty("DataStoreType", "Local"); + public DataSourceType getDataStoreType () { + // OCS_DATASOURCE_TYPE env has higher preference over config.properties + final String dataSource = System.getenv("OCS_DATASOURCE_TYPE"); + if (dataSource == null || dataSource.isEmpty()) { + try { + return DataSourceType.valueOf(prop.getProperty("DataStoreType", "Local")); + } catch (IllegalArgumentException e) { + return DataSourceType.Local; + } + } + try { + return DataSourceType.valueOf(dataSource); + } catch (IllegalArgumentException e) { + return DataSourceType.Local; + } + } + + public Long getDefaultRequestedServiceUnit () { + final String defaultRequestedServiceUnit = System.getProperty("DEFAULT_REQUESTED_SERVICE_UNIT"); + if (defaultRequestedServiceUnit == null || defaultRequestedServiceUnit.isEmpty()) { + return 40_000_000L; + } else { + return Long.parseLong(defaultRequestedServiceUnit); + } } - public String getGrpcServer() { - // OCS_GRPC_SERVER env has higher preference over config.properties - final String grpcServer = System.getenv("OCS_GRPC_SERVER"); - if (grpcServer == null || grpcServer.isEmpty()) { - throw new Error("No OCS_GRPC_SERVER set in env"); + public SecondaryDataSourceType getSecondaryDataStoreType () { + // OCS_SECONDARY_DATASOURCE_TYPE env has higher preference over config.properties + final String secondaryDataSource = System.getenv("OCS_SECONDARY_DATASOURCE_TYPE"); + if (secondaryDataSource == null || secondaryDataSource.isEmpty()) { + try { + return SecondaryDataSourceType.valueOf(prop.getProperty("SecondaryDataStoreType", "PubSub")); + } catch (IllegalArgumentException e) { + return SecondaryDataSourceType.PubSub; + } } - return grpcServer; + try { + return SecondaryDataSourceType.valueOf(secondaryDataSource); + } catch (IllegalArgumentException e) { + return SecondaryDataSourceType.PubSub; + } + } + + public String getGrpcServer() { + return getEnvProperty("OCS_GRPC_SERVER"); } public String getMetricsServer() { - // METRICS_GRPC_SERVER env has higher preference over config.properties - final String metricsServer = System.getenv("METRICS_GRPC_SERVER"); - if (metricsServer == null || metricsServer.isEmpty()) { - throw new Error("No METRICS_GRPC_SERVER set in env"); + return getEnvProperty("METRICS_GRPC_SERVER"); + } + + public String getPubSubProjectId() { + return getEnvProperty("PUBSUB_PROJECT_ID"); + } + + public String getPubSubTopicIdForCcr() { + return getEnvProperty("PUBSUB_CCR_TOPIC_ID"); + } + + public String getPubSubTopicIdForCca() { + return getEnvProperty("PUBSUB_CCA_TOPIC_ID"); + } + + public String getPubSubSubscriptionIdForCca() { + return getEnvProperty("PUBSUB_CCA_SUBSCRIPTION_ID"); + } + + public String getPubSubSubscriptionIdForActivate() { + return getEnvProperty("PUBSUB_ACTIVATE_SUBSCRIPTION_ID"); + } + + + private String getEnvProperty(String propertyName) { + final String value = System.getenv(propertyName); + if (value == null || value.isEmpty()) { + throw new Error("No "+ propertyName + " set in env"); } - return metricsServer; + return value; } } diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/utils/EventConsumer.java b/ocsgw/src/main/java/org/ostelco/ocsgw/utils/EventConsumer.java new file mode 100644 index 000000000..ef5b96436 --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/utils/EventConsumer.java @@ -0,0 +1,44 @@ +package org.ostelco.ocsgw.utils; + +import io.grpc.stub.StreamObserver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ConcurrentLinkedQueue; + +public class EventConsumer implements Runnable { + + private static final Logger LOG = LoggerFactory.getLogger(EventConsumer.class); + + private final ConcurrentLinkedQueue queue; + private StreamObserver streamObserver; + + public EventConsumer(ConcurrentLinkedQueue queue, StreamObserver streamObserver) { + this.queue = queue; + this.streamObserver = streamObserver; + } + + @Override + public void run() { + while(true) { + consume(); + try { + synchronized (queue) { + queue.wait(); + } + } catch (InterruptedException e) { + LOG.info("Interrupted"); + break; + } + } + } + + private void consume() { + while (!queue.isEmpty()) { + T event = queue.poll(); + if (event != null) { + streamObserver.onNext(event); + } + } + } +} \ No newline at end of file diff --git a/ocsgw/src/main/java/org/ostelco/ocsgw/utils/EventProducer.java b/ocsgw/src/main/java/org/ostelco/ocsgw/utils/EventProducer.java new file mode 100644 index 000000000..f75841ffa --- /dev/null +++ b/ocsgw/src/main/java/org/ostelco/ocsgw/utils/EventProducer.java @@ -0,0 +1,27 @@ +package org.ostelco.ocsgw.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ConcurrentLinkedQueue; + +public class EventProducer { + private static final Logger LOG = LoggerFactory.getLogger(EventProducer.class); + + private final ConcurrentLinkedQueue queue; + + public EventProducer(ConcurrentLinkedQueue queue) { + this.queue = queue; + } + + public void queueEvent(T event) { + try { + queue.add(event); + synchronized (queue) { + queue.notifyAll(); + } + } catch (NullPointerException e) { + LOG.error("Failed to queue Event", e); + } + } +} \ No newline at end of file diff --git a/ocsgw/src/main/resources/config.properties b/ocsgw/src/main/resources/config.properties index 193710c77..27a888cf7 100644 --- a/ocsgw/src/main/resources/config.properties +++ b/ocsgw/src/main/resources/config.properties @@ -1,2 +1,4 @@ -# Type of data storage (Local | gRPC | Proxy) -DataStoreType=Proxy \ No newline at end of file +# Type of data storage (Local | gRPC | PubSub | Proxy) +DataStoreType=Proxy +# Type of secondary data storage (gRPC | PubSub) +SecondaryDataStoreType=gRPC \ No newline at end of file diff --git a/ocsgw/src/main/resources/logback.xml b/ocsgw/src/main/resources/logback.xml index 1aee05ef6..565aca9dc 100644 --- a/ocsgw/src/main/resources/logback.xml +++ b/ocsgw/src/main/resources/logback.xml @@ -7,12 +7,14 @@ - + + + - + diff --git a/ocsgw/src/test/java/org/ostelco/ocsgw/OcsApplicationTest.java b/ocsgw/src/test/java/org/ostelco/ocsgw/OcsApplicationTest.java index e4918dd8f..cc9f1c944 100644 --- a/ocsgw/src/test/java/org/ostelco/ocsgw/OcsApplicationTest.java +++ b/ocsgw/src/test/java/org/ostelco/ocsgw/OcsApplicationTest.java @@ -21,8 +21,10 @@ import java.io.UnsupportedEncodingException; import java.net.InetAddress; import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; /** @@ -43,8 +45,8 @@ public class OcsApplicationTest { private static final String PGW_REALM = "loltel"; private static final String APN = "loltel-test"; private static final String MCC_MNC = "24201"; - private static final String MSISDN = "4790300123"; + private static final long DIAMETER_SUCCESS = 2001L; private TestClient client; @@ -53,10 +55,10 @@ public class OcsApplicationTest { @BeforeEach protected void setUp() { - application.start("src/test/resources/"); + application.start("src/test/resources/", "server-jdiameter-config.xml"); client = new TestClient(); - client.initStack("src/test/resources/"); + client.initStack("src/test/resources/", "client-jdiameter-config.xml"); } @AfterEach @@ -64,10 +66,10 @@ protected void tearDown() { client.shutdown(); client = null; - application.shutdown(); + OcsApplication.shutdown(); } - private void simpleCreditControlRequestInit(Session session) { + private void simpleCreditControlRequestInit(Session session, Long requestedBucketSize, Long expectedGrantedBucketSize, Integer ratingGroup, Integer serviceIdentifier) { Request request = client.createRequest( OCS_REALM, @@ -75,30 +77,42 @@ private void simpleCreditControlRequestInit(Session session) { session ); - TestHelper.createInitRequest(request.getAvps(), MSISDN, 500000L); + TestHelper.createInitRequest(request.getAvps(), MSISDN, requestedBucketSize, ratingGroup, serviceIdentifier); client.sendNextRequest(request, session); waitForAnswer(); try { - assertEquals(2001L, client.getResultCodeAvp().getInteger32()); + assertEquals(DIAMETER_SUCCESS, client.getResultCodeAvp().getInteger32()); AvpSet resultAvps = client.getResultAvps(); assertEquals(OCS_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).getUTF8String()); assertEquals(OCS_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).getUTF8String()); assertEquals(RequestType.INITIAL_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); - assertEquals(2001L, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); - assertEquals(1, resultMSCC.getGrouped().getAvp(Avp.SERVICE_IDENTIFIER_CCA).getUnsigned32()); - assertEquals(10, resultMSCC.getGrouped().getAvp(Avp.RATING_GROUP).getUnsigned32()); + assertEquals(DIAMETER_SUCCESS, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + + if (serviceIdentifier > 0) { + assertEquals(serviceIdentifier.longValue(), resultMSCC.getGrouped().getAvp(Avp.SERVICE_IDENTIFIER_CCA).getUnsigned32()); + } + + if (ratingGroup > 0) { + assertEquals(ratingGroup.longValue(), resultMSCC.getGrouped().getAvp(Avp.RATING_GROUP).getUnsigned32()); + } + Avp granted = resultMSCC.getGrouped().getAvp(Avp.GRANTED_SERVICE_UNIT); - assertEquals(500000L, granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); + assertEquals(expectedGrantedBucketSize.longValue(), granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); } catch (AvpDataException e) { LOG.error("Failed to get Result-Code", e); } } - private void simpleCreditControlRequestUpdate(Session session) { + private void simpleCreditControlRequestUpdate(Session session, + Long requestedBucketSize, + Long usedBucketSize, + Long expectedGrantedBucketSize, + Integer ratingGroup, + Integer serviceIdentifier) { Request request = client.createRequest( OCS_REALM, @@ -106,20 +120,29 @@ private void simpleCreditControlRequestUpdate(Session session) { session ); - TestHelper.createUpdateRequest(request.getAvps(), MSISDN, 400000L, 500000L); + TestHelper.createUpdateRequest(request.getAvps(), MSISDN, requestedBucketSize, usedBucketSize, ratingGroup, serviceIdentifier); client.sendNextRequest(request, session); waitForAnswer(); try { - assertEquals(2001L, client.getResultCodeAvp().getInteger32()); + assertEquals(DIAMETER_SUCCESS, client.getResultCodeAvp().getInteger32()); AvpSet resultAvps = client.getResultAvps(); assertEquals(RequestType.UPDATE_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); - assertEquals(2001L, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + assertEquals(DIAMETER_SUCCESS, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + + if (serviceIdentifier > 0) { + assertEquals(serviceIdentifier.longValue(), resultMSCC.getGrouped().getAvp(Avp.SERVICE_IDENTIFIER_CCA).getUnsigned32()); + } + + if (ratingGroup > 0) { + assertEquals(ratingGroup.longValue(), resultMSCC.getGrouped().getAvp(Avp.RATING_GROUP).getUnsigned32()); + } + Avp granted = resultMSCC.getGrouped().getAvp(Avp.GRANTED_SERVICE_UNIT); - assertEquals(400000L, granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); + assertEquals(expectedGrantedBucketSize.longValue(), granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); } catch (AvpDataException e) { LOG.error("Failed to get Result-Code", e); } @@ -129,9 +152,48 @@ private void simpleCreditControlRequestUpdate(Session session) { @Test @DisplayName("Simple Credit-Control-Request Init Update and Terminate") public void simpleCreditControlRequestInitUpdateAndTerminate() { + + final int ratingGroup = 10; + final int serviceIdentifier = 1; + + Session session = client.createSession(); + simpleCreditControlRequestInit(session, 500_000L, 500_000L,ratingGroup, serviceIdentifier); + simpleCreditControlRequestUpdate(session, 400_000L, 500_000L, 400_000L, ratingGroup, serviceIdentifier); + + Request request = client.createRequest( + OCS_REALM, + OCS_HOST, + session + ); + + TestHelper.createTerminateRequest(request.getAvps(), MSISDN, 400_000L, ratingGroup, 1); + + client.sendNextRequest(request, session); + + waitForAnswer(); + + try { + assertEquals(DIAMETER_SUCCESS, client.getResultCodeAvp().getInteger32()); + AvpSet resultAvps = client.getResultAvps(); + assertEquals(RequestType.TERMINATION_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); + Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); + assertEquals(DIAMETER_SUCCESS, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + } catch (AvpDataException e) { + LOG.error("Failed to get Result-Code", e); + } + session.release(); + } + + @Test + @DisplayName("Credit-Control-Request Init Update and Terminate No Requested-Service-Unit Set") + public void CreditControlRequestInitUpdateAndTerminateNoRequestedServiceUnit() { + + final int ratingGroup = 10; + final int serviceIdentifier = -1; + Session session = client.createSession(); - simpleCreditControlRequestInit(session); - simpleCreditControlRequestUpdate(session); + simpleCreditControlRequestInit(session, -1L, 40_000_000L, ratingGroup, serviceIdentifier); + simpleCreditControlRequestUpdate(session, -1L, 40_000_000L, 40_000_000L, ratingGroup, serviceIdentifier); Request request = client.createRequest( OCS_REALM, @@ -139,32 +201,121 @@ public void simpleCreditControlRequestInitUpdateAndTerminate() { session ); - TestHelper.createTerminateRequest(request.getAvps(), MSISDN, 700000L); + TestHelper.createTerminateRequest(request.getAvps(), MSISDN, 40_000_000L, ratingGroup, serviceIdentifier); client.sendNextRequest(request, session); waitForAnswer(); try { - assertEquals(2001L, client.getResultCodeAvp().getInteger32()); + assertEquals(DIAMETER_SUCCESS, client.getResultCodeAvp().getInteger32()); AvpSet resultAvps = client.getResultAvps(); assertEquals(RequestType.TERMINATION_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); - assertEquals(2001L, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + assertEquals(DIAMETER_SUCCESS, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); } catch (AvpDataException e) { LOG.error("Failed to get Result-Code", e); } session.release(); } + @Test + @DisplayName("Credit-Control-Request Multi Ratinggroups Init") + public void creditControlRequestMultiRatingGroupsInit() { + Session session = client.createSession(); + Request request = client.createRequest( + OCS_REALM, + OCS_HOST, + session + ); + + TestHelper.createInitRequestMultiRatingGroups(request.getAvps(), MSISDN, 500_000L); + + client.sendNextRequest(request, session); + + waitForAnswer(); + + try { + assertEquals(DIAMETER_SUCCESS, client.getResultCodeAvp().getInteger32()); + AvpSet resultAvps = client.getResultAvps(); + assertEquals(OCS_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).getUTF8String()); + assertEquals(OCS_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).getUTF8String()); + assertEquals(RequestType.INITIAL_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); + AvpSet resultMSCCs = resultAvps.getAvps(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); + assertEquals(3, resultMSCCs.size()); + for ( int i=0; i < resultMSCCs.size(); i++ ) { + AvpSet mscc = resultMSCCs.getAvpByIndex(i).getGrouped(); + assertEquals(DIAMETER_SUCCESS, mscc.getAvp(Avp.RESULT_CODE).getInteger32()); + Avp granted = mscc.getAvp(Avp.GRANTED_SERVICE_UNIT); + assertEquals(500_000L, granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); + int serviceIdentifier = (int) mscc.getAvp(Avp.SERVICE_IDENTIFIER_CCA).getUnsigned32(); + switch (serviceIdentifier) { + case 1 : + assertEquals(10, mscc.getAvp(Avp.RATING_GROUP).getUnsigned32()); + break; + case 2 : + assertEquals(12, mscc.getAvp(Avp.RATING_GROUP).getUnsigned32()); + break; + case 4 : + assertEquals(14, mscc.getAvp(Avp.RATING_GROUP).getUnsigned32()); + break; + default: + fail("Unexpected Service-Identifier"); + + } + + } + } catch (AvpDataException e) { + LOG.error("Failed to get Result-Code", e); + } + } + + @Test + @DisplayName("test AVP not in Diameter dictionary") + public void testUnknownAVP() { + + final int ratingGroup = 10; + final int serviceIdentifier = 1; + + Session session = client.createSession(); + Request request = client.createRequest( + OCS_REALM, + OCS_HOST, + session + ); + + TestHelper.createInitRequest(request.getAvps(), MSISDN, 500_000L, ratingGroup, serviceIdentifier); + TestHelper.addUnknownApv(request.getAvps()); + + client.sendNextRequest(request, session); + + waitForAnswer(); + + try { + assertEquals(DIAMETER_SUCCESS, client.getResultCodeAvp().getInteger32()); + AvpSet resultAvps = client.getResultAvps(); + assertEquals(OCS_HOST, resultAvps.getAvp(Avp.ORIGIN_HOST).getUTF8String()); + assertEquals(OCS_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).getUTF8String()); + assertEquals(RequestType.INITIAL_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); + Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); + assertEquals(DIAMETER_SUCCESS, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + assertEquals(serviceIdentifier, resultMSCC.getGrouped().getAvp(Avp.SERVICE_IDENTIFIER_CCA).getUnsigned32()); + assertEquals(ratingGroup, resultMSCC.getGrouped().getAvp(Avp.RATING_GROUP).getUnsigned32()); + Avp granted = resultMSCC.getGrouped().getAvp(Avp.GRANTED_SERVICE_UNIT); + assertEquals(500_000L, granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); + } catch (AvpDataException e) { + LOG.error("Failed to get Result-Code", e); + } + } + @Test public void testReAuthRequest() { Session session = client.createSession(); - simpleCreditControlRequestInit(session); + simpleCreditControlRequestInit(session, 500_000L, 500_000L, 10, 1); client.initRequestTest(); - OcsServer.getInstance().sendReAuthRequest(new SessionContext(session.getSessionId(), PGW_HOST, PGW_REALM, APN, MCC_MNC)); + OcsServer.INSTANCE.sendReAuthRequest(new SessionContext(session.getSessionId(), PGW_HOST, PGW_REALM, APN, MCC_MNC)); waitForRequest(); try { AvpSet resultAvps = client.getResultAvps(); @@ -192,7 +343,7 @@ public void serviceInformationCreditControlRequestInit() throws UnsupportedEncod ); AvpSet ccrAvps = request.getAvps(); - TestHelper.createInitRequest(ccrAvps, MSISDN, 500000L); + TestHelper.createInitRequest(ccrAvps, MSISDN, 500000L, 10, 1); AvpSet serviceInformation = ccrAvps.addGroupedAvp(Avp.SERVICE_INFORMATION, VENDOR_ID_3GPP, true, false); AvpSet psInformation = serviceInformation.addGroupedAvp(Avp.PS_INFORMATION, VENDOR_ID_3GPP, true, false); @@ -214,11 +365,11 @@ public void serviceInformationCreditControlRequestInit() throws UnsupportedEncod psInformation.addAvp(Avp.TGPP_CHARGING_CHARACTERISTICS, "0800", VENDOR_ID_3GPP, true, false, true); psInformation.addAvp(Avp.GPP_SGSN_MCC_MNC, "24201", VENDOR_ID_3GPP, true, false, false); byte[] timeZoneBytes = new byte[] {64, 00}; - String timeZone = new String(timeZoneBytes, "UTF-8"); + String timeZone = new String(timeZoneBytes, StandardCharsets.UTF_8); psInformation.addAvp(Avp.TGPP_MS_TIMEZONE, timeZone, VENDOR_ID_3GPP, true, false, true); psInformation.addAvp(Avp.CHARGING_RULE_BASE_NAME, "RB1", VENDOR_ID_3GPP, true, false, false); byte[] ratTypeBytes = new byte[] {06}; - String ratType = new String(ratTypeBytes, "UTF-8"); + String ratType = new String(ratTypeBytes, StandardCharsets.UTF_8); psInformation.addAvp(Avp.TGPP_RAT_TYPE, ratType , VENDOR_ID_3GPP, true, false, true); String s = "8242f21078b542f2100103c703"; @@ -229,7 +380,7 @@ public void serviceInformationCreditControlRequestInit() throws UnsupportedEncod waitForAnswer(); try { - assertEquals(2001L, client.getResultCodeAvp().getInteger32()); + assertEquals(DIAMETER_SUCCESS, client.getResultCodeAvp().getInteger32()); AvpSet resultAvps = client.getResultAvps(); assertEquals(RequestType.INITIAL_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); @@ -247,7 +398,7 @@ private void waitForAnswer() { while (!client.isAnswerReceived() && i<10) { i++; try { - Thread.currentThread().sleep(500); + Thread.sleep(500); } catch (InterruptedException e) { // continue } @@ -260,7 +411,7 @@ private void waitForRequest() { while (!client.isRequestReceived() && i<10) { i++; try { - Thread.currentThread().sleep(500); + Thread.sleep(500); } catch (InterruptedException e) { // continue } diff --git a/ocsgw/src/test/java/org/ostelco/ocsgw/OcsHATest.java b/ocsgw/src/test/java/org/ostelco/ocsgw/OcsHATest.java new file mode 100644 index 000000000..2fe630ab7 --- /dev/null +++ b/ocsgw/src/test/java/org/ostelco/ocsgw/OcsHATest.java @@ -0,0 +1,313 @@ +package org.ostelco.ocsgw; + +import org.jdiameter.api.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.ostelco.diameter.model.RequestType; +import org.ostelco.diameter.test.TestClient; +import org.ostelco.diameter.test.TestHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.channels.SocketChannel; + +import static org.junit.Assert.assertEquals; + + +/** + * Tests for the OcsApplication. This will use a TestClient to + * actually send Diameter traffic on localhost to the OcsApplication. + */ + +@DisplayName("OcsHATest") +public class OcsHATest { + + private static final Logger logger = LoggerFactory.getLogger(OcsHATest.class); + + + private static final String OCS_REALM = "loltel"; + private static final String OCS_HOST_1 = "ocs_1"; + private static final String OCS_HOST_2 = "ocs_2"; + + private static final String MSISDN = "4790300123"; + + private TestClient testPGW; + + private Process ocsgw_1; + private Process ocsgw_2; + + + private void waitForServerToStart(final int server) { + switch (server) { + case 1: + waitForPort("127.0.0.1", 3868,10000); + break; + case 2: + waitForPort("127.0.0.1", 3869,10000); + break; + default: + } + } + + private void waitForPort(String hostname, int port, long timeoutMs) { + logger.debug("Waiting for port " + port); + long startTs = System.currentTimeMillis(); + boolean scanning = true; + while (scanning) + { + if (System.currentTimeMillis() - startTs > timeoutMs) { + logger.error("Timeout waiting for port " + port); + scanning = false; + } + try + { + SocketAddress addr = new InetSocketAddress(hostname, port); + SocketChannel socketChannel = SocketChannel.open(); + socketChannel.configureBlocking(true); + try { + socketChannel.connect(addr); + } + finally { + socketChannel.close(); + } + + scanning = false; + } + catch(IOException e) + { + logger.debug("Still waiting for port " + port); + try + { + Thread.sleep(2000);//2 seconds + } + catch(InterruptedException ie){ + logger.error("Interrupted", ie); + } + } + } + logger.debug("Port " + port + " ready."); + } + + private void waitForProcessExit(Process process) { + while (process.isAlive()) { + try + { + Thread.sleep(2000);//2 seconds + } + catch(InterruptedException ie){ + logger.error("Interrupted", ie); + } + } + } + + private Process startServer(final int server) { + + Process process = null; + ProcessBuilder processBuilder = new ProcessBuilder("java", "-jar", "./build/libs/ocsgw-uber.jar"); + processBuilder.environment().put("DIAMETER_CONFIG_FILE", "server-jdiameter-ha-" + server +"-config.xml"); + processBuilder.environment().put("OCS_DATASOURCE_TYPE", "Local"); + processBuilder.environment().put("CONFIG_FOLDER", "src/test/resources/"); + processBuilder.inheritIO(); + try { + process = processBuilder.start(); + } catch (IOException e) { + logger.error("Failed to start external OCSgw number" + server, e); + } + return process; + } + + + @BeforeEach + protected void setUp() { + logger.debug("setUp()"); + + ocsgw_1 = startServer(1); + ocsgw_2 = startServer(2); + + waitForServerToStart(1); + waitForServerToStart(2); + + testPGW = new TestClient(); + testPGW.initStack("src/test/resources/", "client-jdiameter-ha-config.xml"); + } + + @AfterEach + protected void tearDown() { + logger.debug("tearDown()"); + testPGW.shutdown(); + testPGW = null; + + ocsgw_1.destroy(); + ocsgw_2.destroy(); + } + + private void haCreditControlRequestInit(Session session, String host) { + + Request request = testPGW.createRequest( + OCS_REALM, + host, + session + ); + + TestHelper.createInitRequest(request.getAvps(), MSISDN, 500000L, 1, 10); + + testPGW.sendNextRequest(request, session); + + waitForAnswer(); + + try { + assertEquals(2001L, testPGW.getResultCodeAvp().getInteger32()); + AvpSet resultAvps = testPGW.getResultAvps(); + assertEquals(host, resultAvps.getAvp(Avp.ORIGIN_HOST).getUTF8String()); + assertEquals(OCS_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).getUTF8String()); + assertEquals(RequestType.INITIAL_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); + Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); + assertEquals(2001L, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + assertEquals(1, resultMSCC.getGrouped().getAvp(Avp.SERVICE_IDENTIFIER_CCA).getUnsigned32()); + assertEquals(10, resultMSCC.getGrouped().getAvp(Avp.RATING_GROUP).getUnsigned32()); + Avp granted = resultMSCC.getGrouped().getAvp(Avp.GRANTED_SERVICE_UNIT); + assertEquals(500000L, granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); + } catch (AvpDataException e) { + logger.error("Failed to get Result-Code", e); + } + } + + private void restartServer(final int server) { + switch (server) { + case 1: + stopServer(1); + waitForProcessExit(ocsgw_1); + ocsgw_1 = startServer(1); + waitForServerToStart(1); + break; + case 2: + stopServer(2); + waitForProcessExit(ocsgw_2); + ocsgw_2 = startServer(2); + waitForServerToStart(2); + break; + default: + logger.info("Incorrect server number : " + server); + } + // Give time for ocsgw to reconnect to P-GW + try + { + logger.debug("Pausing testing 10 seconds so that ocsgw can reconnect..."); + Thread.sleep(10000);//10 seconds + logger.debug("Continue testing"); + } + catch(InterruptedException ie){ + logger.error("Interrupted", ie); + } + } + + private void stopServer(final int server) { + switch (server) { + case 1: + ocsgw_1.destroy(); + break; + case 2: + ocsgw_2.destroy(); + break; + default: + } + } + + private void haCreditControlRequestUpdate(Session session, String host) { + + Request request = testPGW.createRequest( + OCS_REALM, + host, + session + ); + + TestHelper.createUpdateRequest(request.getAvps(), MSISDN, 400000L, 500000L, 1, 10); + + testPGW.sendNextRequest(request, session); + + waitForAnswer(); + + try { + assertEquals(2001L, testPGW.getResultCodeAvp().getInteger32()); + AvpSet resultAvps = testPGW.getResultAvps(); + assertEquals(host, resultAvps.getAvp(Avp.ORIGIN_HOST).getUTF8String()); + assertEquals(OCS_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).getUTF8String()); + assertEquals(RequestType.UPDATE_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); + Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); + assertEquals(2001L, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + Avp granted = resultMSCC.getGrouped().getAvp(Avp.GRANTED_SERVICE_UNIT); + assertEquals(400000L, granted.getGrouped().getAvp(Avp.CC_TOTAL_OCTETS).getUnsigned64()); + } catch (AvpDataException e) { + logger.error("Failed to get Result-Code", e); + } + } + + /** + * This is only meant to be used for local testing. As it is starting external processes for the + * OCSgw you will have to take care to clean up. + * + * The test will create a session. It will restart server 1 before it sends the CCR-Update message. + * Then restart server 2 and send another CCR-Update message. This will only work if state was shared + * as the session would otherwise have been lost by ocsgw. + */ + @DisplayName("HA Credit-Control-Request Init Update and Terminate") + public void haCreditControlRequestInitUpdateAndTerminate() { + Session session = testPGW.createSession(); + haCreditControlRequestInit(session, OCS_HOST_1); + + // Restart server 1 and continue when it is back online + restartServer(1); + + haCreditControlRequestUpdate(session, OCS_HOST_1); + + // Stop server 1 and hand over request to server 2 + stopServer(1); + haCreditControlRequestUpdate(session, OCS_HOST_2); + + // Restart server 2 and continue once it is back up + restartServer(2); + + Request request = testPGW.createRequest( + OCS_REALM, + OCS_HOST_2, + session + ); + + TestHelper.createTerminateRequest(request.getAvps(), MSISDN, 700000L, 1, 10); + + testPGW.sendNextRequest(request, session); + + waitForAnswer(); + + try { + assertEquals(2001L, testPGW.getResultCodeAvp().getInteger32()); + AvpSet resultAvps = testPGW.getResultAvps(); + assertEquals(OCS_HOST_2, resultAvps.getAvp(Avp.ORIGIN_HOST).getUTF8String()); + assertEquals(OCS_REALM, resultAvps.getAvp(Avp.ORIGIN_REALM).getUTF8String()); + assertEquals(RequestType.TERMINATION_REQUEST, resultAvps.getAvp(Avp.CC_REQUEST_TYPE).getInteger32()); + Avp resultMSCC = resultAvps.getAvp(Avp.MULTIPLE_SERVICES_CREDIT_CONTROL); + assertEquals(2001L, resultMSCC.getGrouped().getAvp(Avp.RESULT_CODE).getInteger32()); + } catch (AvpDataException e) { + logger.error("Failed to get Result-Code", e); + } + session.release(); + } + + private void waitForAnswer() { + int i = 0; + while (!testPGW.isAnswerReceived() && i<10) { + i++; + try { + Thread.sleep(500); + } catch (InterruptedException e) { + // continue + } + } + assertEquals(true, testPGW.isAnswerReceived()); + } +} \ No newline at end of file diff --git a/ocsgw/src/test/resources/client-jdiameter-ha-config.xml b/ocsgw/src/test/resources/client-jdiameter-ha-config.xml new file mode 100644 index 000000000..461771199 --- /dev/null +++ b/ocsgw/src/test/resources/client-jdiameter-ha-config.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ocsgw/src/test/resources/config.properties b/ocsgw/src/test/resources/config.properties index 2718e9b61..25106690a 100644 --- a/ocsgw/src/test/resources/config.properties +++ b/ocsgw/src/test/resources/config.properties @@ -1,2 +1,4 @@ -# Type of data storage (Local | gRPC | Proxy) -DataStoreType=Local \ No newline at end of file +# Type of data storage (Local | gRPC | PubSub | Proxy) +DataStoreType=Local +# Type of secondary data storage (gRPC | PubSub) +# SecondaryDataStoreType=PubSub \ No newline at end of file diff --git a/ocsgw/src/test/resources/logback.xml b/ocsgw/src/test/resources/logback-test.xml similarity index 84% rename from ocsgw/src/test/resources/logback.xml rename to ocsgw/src/test/resources/logback-test.xml index 01c8f6a8a..083a5b652 100644 --- a/ocsgw/src/test/resources/logback.xml +++ b/ocsgw/src/test/resources/logback-test.xml @@ -3,7 +3,7 @@ - %d{dd MMM yyyy HH:mm:ss,SSS} %-5p %c{1} - %m%n + %d{dd MMM yyyy HH:mm:ss,SSS} %highlight(%-5p) [%thread] %c{0} - %m%n diff --git a/ocsgw/src/test/resources/server-jdiameter-config.xml b/ocsgw/src/test/resources/server-jdiameter-config.xml index 6a740cf8e..e96b5f0fb 100644 --- a/ocsgw/src/test/resources/server-jdiameter-config.xml +++ b/ocsgw/src/test/resources/server-jdiameter-config.xml @@ -74,6 +74,7 @@ - + + \ No newline at end of file diff --git a/ocsgw/src/test/resources/server-jdiameter-ha-1-config.xml b/ocsgw/src/test/resources/server-jdiameter-ha-1-config.xml new file mode 100644 index 000000000..98f2d936e --- /dev/null +++ b/ocsgw/src/test/resources/server-jdiameter-ha-1-config.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ocsgw/src/test/resources/server-jdiameter-ha-2-config.xml b/ocsgw/src/test/resources/server-jdiameter-ha-2-config.xml new file mode 100644 index 000000000..e195b7f1e --- /dev/null +++ b/ocsgw/src/test/resources/server-jdiameter-ha-2-config.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ostelco-lib/build.gradle b/ostelco-lib/build.gradle index d492cff66..f7ddd342f 100644 --- a/ostelco-lib/build.gradle +++ b/ostelco-lib/build.gradle @@ -16,18 +16,18 @@ dependencies { testImplementation "org.assertj:assertj-core:$assertJVersion" // https://mvnrepository.com/artifact/org.glassfish.jersey.test-framework.providers/jersey-test-framework-provider-grizzly2 - testCompile("org.glassfish.jersey.test-framework.providers:jersey-test-framework-provider-grizzly2:2.27") { + testCompile("org.glassfish.jersey.test-framework.providers:jersey-test-framework-provider-grizzly2:2.28") { // 2.26 (latest) exclude group: 'javax.servlet', module: 'javax.servlet-api' exclude group: 'junit', module: 'junit' } // https://mvnrepository.com/artifact/org.jetbrains/annotations - implementation 'org.jetbrains:annotations:16.0.3' + implementation 'org.jetbrains:annotations:17.0.0' } lombok { - version = '1.18.2' + version = '1.18.6' sha256 = "" } @@ -44,4 +44,4 @@ jar { } } -apply from: '../jacoco.gradle' +apply from: '../gradle/jacoco.gradle' diff --git a/payment-processor/build.gradle b/payment-processor/build.gradle index ff3d9c8b6..a195891ec 100644 --- a/payment-processor/build.gradle +++ b/payment-processor/build.gradle @@ -1,5 +1,5 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" id "idea" } @@ -14,12 +14,14 @@ sourceSets { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - implementation project(":prime-modules") + implementation project(":data-store") implementation "com.stripe:stripe-java:$stripeVersion" + implementation "com.google.cloud:google-cloud-pubsub:$googleCloudVersion" + implementation "com.google.cloud:google-cloud-datastore:$googleCloudVersion" + testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" @@ -47,7 +49,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { } } -apply from: '../jacoco.gradle' +apply from: '../gradle/jacoco.gradle' idea { module { diff --git a/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt b/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt index d27422cbc..561685a1f 100644 --- a/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt +++ b/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt @@ -8,6 +8,7 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.ostelco.prime.module.getResource +import java.util.* import kotlin.test.assertEquals import kotlin.test.fail @@ -15,7 +16,8 @@ import kotlin.test.fail class StripePaymentProcessorTest { private val paymentProcessor = getResource() - private val testCustomer = "testuser@StripePaymentProcessorTest.ok" + private val testCustomer = UUID.randomUUID().toString() + private val emailTestCustomer = "test@internet.org" private var stripeCustomerId = "" @@ -54,7 +56,7 @@ class StripePaymentProcessorTest { } private fun addCustomer() { - val resultAdd = paymentProcessor.createPaymentProfile(testCustomer) + val resultAdd = paymentProcessor.createPaymentProfile(customerId = testCustomer, email = emailTestCustomer) assertEquals(true, resultAdd.isRight()) stripeCustomerId = resultAdd.fold({ "" }, { it.id }) @@ -74,7 +76,7 @@ class StripePaymentProcessorTest { @Test fun unknownCustomerGetSavedSources() { - val result = paymentProcessor.getSavedSources(customerId = "unknown") + val result = paymentProcessor.getSavedSources(stripeCustomerId = "unknown") assertEquals(true, result.isLeft()) } @@ -129,7 +131,7 @@ class StripePaymentProcessorTest { } sourcesRemoved.forEach { it -> - assertEquals(true, it.isRight(), "Unexpected failure when removing source ${it}") + assertEquals(true, it.isRight(), "Unexpected failure when removing source $it") } } @@ -193,10 +195,31 @@ class StripePaymentProcessorTest { val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) assertEquals(true, resultAddSource.isRight()) - val resultAuthorizeCharge = paymentProcessor.authorizeCharge(stripeCustomerId, resultAddSource.fold({ "" }, { it.id }), 1000, "nok") + val amount = 1000 + val currency = "NOK" + + val resultAuthorizeCharge = paymentProcessor.authorizeCharge(stripeCustomerId, resultAddSource.fold({ "" }, { it.id }), amount, currency) + assertEquals(true, resultAuthorizeCharge.isRight()) + + val resultRefundCharge = paymentProcessor.refundCharge(resultAuthorizeCharge.fold({ "" }, { it } ), amount, currency) + assertEquals(true, resultRefundCharge.isRight()) + + val resultRemoveSource = paymentProcessor.removeSource(stripeCustomerId, resultAddSource.fold({ "" }, { it.id })) + assertEquals(true, resultRemoveSource.isRight()) + } + + @Test + fun createAuthorizeChargeAndRefundWithZeroAmount() { + val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + assertEquals(true, resultAddSource.isRight()) + + val amount = 0 + val currency = "NOK" + + val resultAuthorizeCharge = paymentProcessor.authorizeCharge(stripeCustomerId, resultAddSource.fold({ "" }, { it.id }), amount, currency) assertEquals(true, resultAuthorizeCharge.isRight()) - val resultRefundCharge = paymentProcessor.refundCharge(resultAuthorizeCharge.fold({ "" }, { it } )) + val resultRefundCharge = paymentProcessor.refundCharge(resultAuthorizeCharge.fold({ "" }, { it } ), amount, currency) assertEquals(true, resultRefundCharge.isRight()) assertEquals(resultAuthorizeCharge.fold({ "" }, { it } ), resultRefundCharge.fold({ "" }, { it } )) @@ -226,7 +249,7 @@ class StripePaymentProcessorTest { val resultCreatePlan = paymentProcessor.createPlan(resultCreateProduct.fold({ "" }, { it.id }), 1000, "NOK", PaymentProcessor.Interval.MONTH) assertEquals(true, resultCreatePlan.isRight()) - val resultSubscribePlan = paymentProcessor.subscribeToPlan(resultCreatePlan.fold({ "" }, { it.id }), stripeCustomerId) + val resultSubscribePlan = paymentProcessor.createSubscription(resultCreatePlan.fold({ "" }, { it.id }), stripeCustomerId) assertEquals(true, resultSubscribePlan.isRight()) val resultUnsubscribePlan = paymentProcessor.cancelSubscription(resultSubscribePlan.fold({ "" }, { it.id }), false) diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessorModule.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessorModule.kt index a30544fd9..04ab1f44a 100644 --- a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessorModule.kt +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessorModule.kt @@ -1,18 +1,81 @@ package org.ostelco.prime.paymentprocessor +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonTypeName import com.stripe.Stripe import io.dropwizard.setup.Environment +import org.hibernate.validator.constraints.NotEmpty import org.ostelco.prime.getLogger import org.ostelco.prime.module.PrimeModule +import org.ostelco.prime.paymentprocessor.publishers.StripeEventPublisher +import org.ostelco.prime.paymentprocessor.resources.StripeWebhookResource +import org.ostelco.prime.paymentprocessor.subscribers.ReportStripeEvent +import org.ostelco.prime.paymentprocessor.subscribers.StoreStripeEvent @JsonTypeName("stripe-payment-processor") class PaymentProcessorModule : PrimeModule { private val logger by getLogger() + @JsonProperty("config") + fun setConfig(config: PaymentProcessorConfig) { + ConfigRegistry.config = config + } + override fun init(env: Environment) { logger.info("PaymentProcessor init with $env") - Stripe.apiKey = System.getenv("STRIPE_API_KEY") ?: throw Error("Missing environment variable STRIPE_API_KEY") + + /* Stripe requires the use an API key. + https://stripe.com/docs/keys */ + Stripe.apiKey = System.getenv("STRIPE_API_KEY") + ?: throw Error("Missing environment variable STRIPE_API_KEY") + + val jerseyEnv = env.jersey() + + /* APIs. */ + jerseyEnv.register(StripeWebhookResource()) + + /* Stripe events reporting. */ + env.lifecycle().manage(StripeEventPublisher) + env.lifecycle().manage(StoreStripeEvent()) + env.lifecycle().manage(ReportStripeEvent()) } } + +class PaymentProcessorConfig { + @NotEmpty + @JsonProperty("projectId") + lateinit var projectId: String + + @NotEmpty + @JsonProperty("stripeEventTopicId") + lateinit var stripeEventTopicId: String + + @NotEmpty + @JsonProperty("stripeEventStoreSubscriptionId") + lateinit var stripeEventStoreSubscriptionId: String + + @NotEmpty + @JsonProperty("stripeEventReportSubscriptionId") + lateinit var stripeEventReportSubscriptionId: String + + @JsonProperty("stripeEventStoreType") + var stripeEventStoreType: String = "default" + + /* Same as 'table name' in other DBs. */ + @JsonProperty("stripeEventKind") + var stripeEventKind: String = "stripe-events" + + /* Can be used to set 'namespace' in Datastore. + Not used if set to an emtpy string. */ + @JsonProperty("namespace") + var namespace: String = "" + + /* Only used by Datastore emulator. */ + @JsonProperty("hostport") + var hostport: String = "localhost:9090" +} + +object ConfigRegistry { + lateinit var config: PaymentProcessorConfig +} diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt index af56dbe54..9222d4623 100644 --- a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt @@ -1,22 +1,17 @@ package org.ostelco.prime.paymentprocessor import arrow.core.Either +import arrow.core.Try import arrow.core.flatMap +import arrow.core.right import com.stripe.exception.ApiConnectionException import com.stripe.exception.AuthenticationException import com.stripe.exception.CardException import com.stripe.exception.InvalidRequestException import com.stripe.exception.RateLimitException import com.stripe.exception.StripeException -import com.stripe.model.Card -import com.stripe.model.Charge -import com.stripe.model.Customer -import com.stripe.model.ExternalAccount -import com.stripe.model.Plan -import com.stripe.model.Product -import com.stripe.model.Refund -import com.stripe.model.Source -import com.stripe.model.Subscription +import com.stripe.model.* +import com.stripe.net.RequestOptions import org.ostelco.prime.getLogger import org.ostelco.prime.paymentprocessor.core.BadGatewayError import org.ostelco.prime.paymentprocessor.core.ForbiddenError @@ -28,15 +23,17 @@ import org.ostelco.prime.paymentprocessor.core.ProfileInfo import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo import org.ostelco.prime.paymentprocessor.core.SourceInfo import org.ostelco.prime.paymentprocessor.core.SubscriptionInfo +import java.time.Instant +import java.util.* class StripePaymentProcessor : PaymentProcessor { private val logger by getLogger() - override fun getSavedSources(customerId: String): Either> = - either("Failed to retrieve sources for customer $customerId") { - val customer = Customer.retrieve(customerId) + override fun getSavedSources(stripeCustomerId: String): Either> = + either("Failed to retrieve sources for customer $stripeCustomerId") { + val customer = Customer.retrieve(stripeCustomerId) val sources: List = customer.sources.data.map { val details = getAccountDetails(it) SourceDetailsInfo(it.id, getAccountType(details), details) @@ -44,58 +41,56 @@ class StripePaymentProcessor : PaymentProcessor { sources.sortedByDescending { it.details["created"] as Long } } - private fun getAccountType(details: Map) : String { + private fun getAccountType(details: Map): String { return details["type"].toString() } - /* Returns detailed 'account details' for the given Stripe source/account. + /* Returns 'source' details for the given Stripe source/account. Note that including the fields 'id', 'type' and 'created' are mandatory. */ - private fun getAccountDetails(accountInfo: ExternalAccount) : Map { - when (accountInfo) { - is Card -> { - return mapOf("id" to accountInfo.id, - "type" to "card", - "addressLine1" to accountInfo.addressLine1, - "addressLine2" to accountInfo.addressLine2, - "addressZip" to accountInfo.addressZip, - "addressCity" to accountInfo.addressCity, - "addressState" to accountInfo.addressState, - "brand" to accountInfo.brand, // "Visa", "Mastercard" etc. - "country" to accountInfo.country, - "currency" to accountInfo.currency, - "cvcCheck" to accountInfo.cvcCheck, - "created" to getCreatedTimestampFromMetadata(accountInfo.id, - accountInfo.metadata), - "expMonth" to accountInfo.expMonth, - "expYear" to accountInfo.expYear, - "fingerprint" to accountInfo.fingerprint, - "funding" to accountInfo.funding, - "last4" to accountInfo.last4, // Typ.: "credit" or "debit" - "threeDSecure" to accountInfo.threeDSecure) + private fun getAccountDetails(paymentSource: PaymentSource): Map = + when (paymentSource) { + is Card -> { + mapOf("id" to paymentSource.id, + "type" to "card", + "addressLine1" to paymentSource.addressLine1, + "addressLine2" to paymentSource.addressLine2, + "addressZip" to paymentSource.addressZip, + "addressCity" to paymentSource.addressCity, + "addressState" to paymentSource.addressState, + "brand" to paymentSource.brand, // "Visa", "Mastercard" etc. + "country" to paymentSource.country, + "currency" to paymentSource.currency, + "cvcCheck" to paymentSource.cvcCheck, + "created" to getCreatedTimestampFromMetadata(paymentSource.id, + paymentSource.metadata), + "expMonth" to paymentSource.expMonth, + "expYear" to paymentSource.expYear, + "fingerprint" to paymentSource.fingerprint, + "funding" to paymentSource.funding, + "last4" to paymentSource.last4) // Typ.: "credit" or "debit" .filterValues { it != null } + } + is Source -> { + mapOf("id" to paymentSource.id, + "type" to "source", + "created" to paymentSource.created, + "owner" to paymentSource.owner, + "threeDSecure" to paymentSource.threeDSecure) + } + else -> { + logger.error("Received unsupported Stripe source/account type: {}", + paymentSource) + mapOf("id" to paymentSource.id, + "type" to "unsupported", + "created" to getSecondsSinceEpoch()) + } } - is Source -> { - return mapOf("id" to accountInfo.id, - "type" to "source", - "created" to accountInfo.created, - "typeData" to accountInfo.typeData, - "owner" to accountInfo.owner) - } - else -> { - logger.error("Received unsupported Stripe source/account type: {}", - accountInfo) - return mapOf("id" to accountInfo.id, - "type" to "unsupported", - "created" to getSecondsSinceEpoch()) - } - } - } /* Handle type conversion when reading the 'created' field from the metadata returned from Stripe. (It might seem like that Stripe returns stored metadata values as strings, even if they where stored using an another type. Needs to be verified.) */ - private fun getCreatedTimestampFromMetadata(id: String, metadata: Map) : Long { + private fun getCreatedTimestampFromMetadata(id: String, metadata: Map): Long { val created: String? = metadata["created"] as? String return created?.toLongOrNull() ?: run { logger.warn("No 'created' timestamp found in metadata for Stripe account {}", @@ -105,42 +100,52 @@ class StripePaymentProcessor : PaymentProcessor { } /* Seconds since Epoch in UTC zone. */ - private fun getSecondsSinceEpoch() : Long { + private fun getSecondsSinceEpoch(): Long { return System.currentTimeMillis() / 1000L } - override fun createPaymentProfile(userEmail: String): Either = - either("Failed to create profile for user $userEmail") { - val customerParams = mapOf("email" to userEmail) + override fun createPaymentProfile(customerId: String, email: String): Either = + either("Failed to create profile for user $customerId") { + val customerParams = mapOf( + "id" to customerId, + "email" to email, + "metadata" to mapOf("customerId" to customerId)) ProfileInfo(Customer.create(customerParams).id) } - override fun getPaymentProfile(userEmail: String): Either { - val customerParams = mapOf( - "limit" to "1", - "email" to userEmail) - val customerList = Customer.list(customerParams) - return when { - customerList.data.isEmpty() -> Either.left(NotFoundError("Could not find a payment profile for user $userEmail")) - customerList.data.size > 1 -> Either.left(NotFoundError("Multiple profiles for user $userEmail found")) - else -> Either.right(ProfileInfo(customerList.data.first().id)) - } - } + override fun getPaymentProfile(customerId: String): Either = + Try { + Customer.retrieve(customerId) + }.fold( + ifSuccess = { customer -> + when { + customer.deleted == true -> Either.left(NotFoundError("Payment profile for user $customerId was previously deleted")) + else -> Either.right(ProfileInfo(customer.id)) + } + }, + ifFailure = { + Either.left(NotFoundError("Could not find a payment profile for user $customerId")) + } + ) - override fun createPlan(productId: String, amount: Int, currency: String, interval: PaymentProcessor.Interval): Either = - either("Failed to create plan with product id $productId amount $amount currency $currency interval ${interval.value}") { + override fun createPlan(productId: String, amount: Int, currency: String, interval: PaymentProcessor.Interval, intervalCount: Long): Either = + either("Failed to create plan for product $productId amount $amount currency $currency interval ${interval.value}") { val planParams = mapOf( + "product" to productId, "amount" to amount, "interval" to interval.value, - "product" to productId, + "interval_count" to intervalCount, "currency" to currency) - PlanInfo(Plan.create(planParams).id) + val plan = Plan.create(planParams) + PlanInfo(plan.id) } override fun removePlan(planId: String): Either = either("Failed to delete plan $planId") { - val plan = Plan.retrieve(planId) - PlanInfo(plan.delete().id) + Plan.retrieve(planId).let { plan -> + plan.delete() + PlanInfo(plan.id) + } } override fun createProduct(sku: String): Either = @@ -157,104 +162,117 @@ class StripePaymentProcessor : PaymentProcessor { ProductInfo(product.delete().id) } - override fun addSource(customerId: String, sourceId: String): Either = - either("Failed to add source $sourceId to customer $customerId") { - val customer = Customer.retrieve(customerId) - val params = mapOf("source" to sourceId, + override fun addSource(stripeCustomerId: String, stripeSourceId: String): Either = + either("Failed to add source $stripeSourceId to customer $stripeCustomerId") { + val customer = Customer.retrieve(stripeCustomerId) + val sourceParams = mapOf("source" to stripeSourceId, "metadata" to mapOf("created" to getSecondsSinceEpoch())) - SourceInfo(customer.sources.create(params).id) + SourceInfo(customer.sources.create(sourceParams).id) } - override fun setDefaultSource(customerId: String, sourceId: String): Either = - either("Failed to set default source $sourceId for customer $customerId") { - val customer = Customer.retrieve(customerId) + override fun setDefaultSource(stripeCustomerId: String, sourceId: String): Either = + either("Failed to set default source $sourceId for customer $stripeCustomerId") { + val customer = Customer.retrieve(stripeCustomerId) val updateParams = mapOf("default_source" to sourceId) val customerUpdated = customer.update(updateParams) SourceInfo(customerUpdated.defaultSource) } - override fun getDefaultSource(customerId: String): Either = - either("Failed to get default source for customer $customerId") { - SourceInfo(Customer.retrieve(customerId).defaultSource) + override fun getDefaultSource(stripeCustomerId: String): Either = + either("Failed to get default source for customer $stripeCustomerId") { + SourceInfo(Customer.retrieve(stripeCustomerId).defaultSource) } - override fun deletePaymentProfile(customerId: String): Either = - either("Failed to delete customer $customerId") { - val customer = Customer.retrieve(customerId) + override fun deletePaymentProfile(stripeCustomerId: String): Either = + either("Failed to delete customer $stripeCustomerId") { + val customer = Customer.retrieve(stripeCustomerId) ProfileInfo(customer.delete().id) } - override fun subscribeToPlan(planId: String, customerId: String): Either = - either("Failed to subscribe customer $customerId to plan $planId") { + override fun createSubscription(planId: String, stripeCustomerId: String, trialEnd: Long): Either = + either("Failed to subscribe customer $stripeCustomerId to plan $planId") { val item = mapOf("plan" to planId) - val params = mapOf( - "customer" to customerId, - "items" to mapOf("0" to item)) - - SubscriptionInfo(Subscription.create(params).id) + val subscriptionParams = mapOf( + "customer" to stripeCustomerId, + "items" to mapOf("0" to item), + *( if (trialEnd > Instant.now().epochSecond) + arrayOf("trial_end" to trialEnd.toString()) + else + arrayOf()) ) + val subscription = Subscription.create(subscriptionParams) + SubscriptionInfo(subscription.id, subscription.created, subscription.trialEnd ?: 0L) } override fun cancelSubscription(subscriptionId: String, atIntervalEnd: Boolean): Either = either("Failed to unsubscribe subscription Id : $subscriptionId atIntervalEnd $atIntervalEnd") { val subscription = Subscription.retrieve(subscriptionId) val subscriptionParams = mapOf("at_period_end" to atIntervalEnd) - SubscriptionInfo(subscription.cancel(subscriptionParams).id) + subscription.cancel(subscriptionParams) + SubscriptionInfo(subscription.id, subscription.created, subscription.trialEnd ?: 0L) } - override fun authorizeCharge(customerId: String, sourceId: String?, amount: Int, currency: String): Either { val errorMessage = "Failed to authorize the charge for customerId $customerId sourceId $sourceId amount $amount currency $currency" - return either(errorMessage) { - val chargeParams = mutableMapOf( - "amount" to amount, - "currency" to currency, - "customer" to customerId, - "capture" to false) - if (sourceId != null) { - chargeParams["source"] = sourceId + return when (amount) { + 0 -> Either.right("ZERO_CHARGE_${UUID.randomUUID()}") + else -> either(errorMessage) { + val chargeParams = mutableMapOf( + "amount" to amount, + "currency" to currency, + "customer" to customerId, + "capture" to false) + if (sourceId != null) { + chargeParams["source"] = sourceId + } + Charge.create(chargeParams) + }.flatMap { charge: Charge -> + val review = charge.review + Either.cond( + test = (review == null), + ifTrue = { charge.id }, + ifFalse = { ForbiddenError("Review required, $errorMessage $review") } + ) } - Charge.create(chargeParams) - }.flatMap { charge: Charge -> - val review = charge.review - Either.cond( - test = (review == null), - ifTrue = { charge.id }, - ifFalse = { ForbiddenError("Review required, $errorMessage $review") } - ) } } - override fun captureCharge(chargeId: String, customerId: String): Either { + override fun captureCharge(chargeId: String, customerId: String, amount: Int, currency: String): Either { val errorMessage = "Failed to capture charge for customerId $customerId chargeId $chargeId" - return either(errorMessage) { - Charge.retrieve(chargeId) - }.flatMap { charge: Charge -> - val review = charge.review - Either.cond( - test = (review == null), - ifTrue = { charge }, - ifFalse = { ForbiddenError("Review required, $errorMessage $review") } - ) - }.flatMap { charge -> - try { - charge.capture() - Either.right(charge.id) - } catch (e: Exception) { - logger.warn(errorMessage, e) - Either.left(BadGatewayError(errorMessage)) + return when (amount) { + 0 -> Either.right(chargeId) + else -> either(errorMessage) { + Charge.retrieve(chargeId) + }.flatMap { charge: Charge -> + val review = charge.review + Either.cond( + test = (review == null), + ifTrue = { charge }, + ifFalse = { ForbiddenError("Review required, $errorMessage $review") } + ) + }.flatMap { charge -> + try { + charge.capture() + Either.right(charge.id) + } catch (e: Exception) { + logger.warn(errorMessage, e) + Either.left(BadGatewayError(errorMessage)) + } } } } - override fun refundCharge(chargeId: String): Either = - either("Failed to refund charge $chargeId") { - val refundParams = mapOf("charge" to chargeId) - Refund.create(refundParams).charge + override fun refundCharge(chargeId: String, amount: Int, currency: String): Either = + when (amount) { + 0 -> Either.right(chargeId) + else -> either("Failed to refund charge $chargeId") { + val refundParams = mapOf("charge" to chargeId) + Refund.create(refundParams).id + } } - override fun removeSource(customerId: String, sourceId: String): Either = - either("Failed to remove source $sourceId from customer $customerId") { - val accountInfo = Customer.retrieve(customerId).sources.retrieve(sourceId) + override fun removeSource(stripeCustomerId: String, sourceId: String): Either = + either("Failed to remove source $sourceId for stripeCustomerId $stripeCustomerId") { + val accountInfo = Customer.retrieve(stripeCustomerId).sources.retrieve(sourceId) when (accountInfo) { is Card -> accountInfo.delete() is Source -> accountInfo.detach() @@ -264,6 +282,21 @@ class StripePaymentProcessor : PaymentProcessor { SourceInfo(sourceId) } + override fun getStripeEphemeralKey(customerId: String, email: String, apiVersion: String): Either = + getPaymentProfile(customerId) + .fold( + { createPaymentProfile(customerId, email) }, + { profileInfo -> profileInfo.right() } + ).flatMap { profileInfo -> + either("Failed to create stripe ephemeral key") { + EphemeralKey.create( + mapOf("customer" to profileInfo.id), + RequestOptions.builder().setStripeVersionOverride(apiVersion).build()) + .rawJson + } + } + + private fun either(errorDescription: String, action: () -> RETURN): Either { return try { Either.right(action()) @@ -278,7 +311,7 @@ class StripePaymentProcessor : PaymentProcessor { } catch (e: InvalidRequestException) { // Invalid parameters were supplied to Stripe's API logger.warn("Payment error : $errorDescription , Stripe Error Code: ${e.code}", e) - Either.left(NotFoundError(errorDescription, e.message)) + Either.left(ForbiddenError(errorDescription, e.message)) } catch (e: AuthenticationException) { // Authentication with Stripe's API failed // (maybe you changed API keys recently) @@ -299,4 +332,3 @@ class StripePaymentProcessor : PaymentProcessor { } } } - diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/publishers/StripeEventPublisher.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/publishers/StripeEventPublisher.kt new file mode 100644 index 000000000..79bbcfdcd --- /dev/null +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/publishers/StripeEventPublisher.kt @@ -0,0 +1,54 @@ +package org.ostelco.prime.paymentprocessor.publishers + +import com.google.api.core.ApiFutureCallback +import com.google.api.core.ApiFutures +import com.google.api.gax.rpc.ApiException +import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp +import com.google.pubsub.v1.PubsubMessage +import com.stripe.model.Event +import org.ostelco.prime.getLogger +import org.ostelco.prime.paymentprocessor.ConfigRegistry +import org.ostelco.prime.pubsub.DelegatePubSubPublisher +import org.ostelco.prime.pubsub.PubSubPublisher +import java.time.Instant + + +object StripeEventPublisher : + PubSubPublisher by DelegatePubSubPublisher(topicId = ConfigRegistry.config.stripeEventTopicId, + projectId = ConfigRegistry.config.projectId) { + + private val logger by getLogger() + + fun publish(event: Event) { + + val message = PubsubMessage.newBuilder() + .setPublishTime(Timestamp.newBuilder() + .setSeconds(Instant.now().epochSecond)) + .setData(event.byteString()) + .build() + val future = publishPubSubMessage(message) + + ApiFutures.addCallback(future, object : ApiFutureCallback { + + override fun onFailure(throwable: Throwable) { + if (throwable is ApiException) { + logger.warn("Error publishing Stripe event: {} (status code: {}, retrying: {})", + event.id, throwable.statusCode.code, throwable.isRetryable) + } else { + logger.warn("Error publishing Stripe event: {}", event.id) + } + } + + /* Once published, returns server-assigned message ids (unique + within the topic) */ + override fun onSuccess(messageId: String) { + logger.debug("Published Stripe event {} as message {}", event.id, + messageId) + } + }, singleThreadScheduledExecutor) + } + + /* Monkeypatching uber alles! */ + private fun Event.byteString(): ByteString = ByteString.copyFromUtf8(this.toJson()) +} \ No newline at end of file diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/resources/StripeWebhookResource.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/resources/StripeWebhookResource.kt new file mode 100644 index 000000000..ec0ab6de5 --- /dev/null +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/resources/StripeWebhookResource.kt @@ -0,0 +1,60 @@ +package org.ostelco.prime.paymentprocessor.resources + +import arrow.core.Try +import com.google.gson.JsonSyntaxException +import com.stripe.exception.SignatureVerificationException +import com.stripe.net.Webhook +import org.ostelco.prime.getLogger +import org.ostelco.prime.paymentprocessor.publishers.StripeEventPublisher +import javax.validation.Valid +import javax.validation.constraints.NotNull +import javax.ws.rs.HeaderParam +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.Response + +/** + * Webhook service for handling Stripe event reports. + * + */ +@Path("/stripe/event") +class StripeWebhookResource() { + + private val logger by getLogger() + + /* Generated by Stripe and can be obtained from the console. */ + private val endpointSecret = System.getenv("STRIPE_ENDPOINT_SECRET") + ?: throw Error("Missing environment variable STRIPE_ENDPOINT_SECRET") + + @POST + @Produces("application/json") + fun handleEvent(@NotNull @Valid @HeaderParam("Stripe-Signature") + signature: String, + @NotNull @Valid + payload: String): Response = + Try { + Webhook.constructEvent(payload, signature, endpointSecret) + }.fold( + ifSuccess = { + StripeEventPublisher.publish(it) + Response.status(Response.Status.OK) + .build() + }, + ifFailure = { + when (it) { + is JsonSyntaxException -> { + logger.error("Invalid payload in Stripe event ${it}") + } + is SignatureVerificationException -> { + logger.error("Invalid signature for Stripe event ${it}") + } + else -> { + logger.error("Unexpected error for Stripe event ${it}") + } + } + Response.status(Response.Status.BAD_REQUEST) + .build() + } + ) +} diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/ReportStripeEvent.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/ReportStripeEvent.kt new file mode 100644 index 000000000..c3be212b8 --- /dev/null +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/ReportStripeEvent.kt @@ -0,0 +1,41 @@ +package org.ostelco.prime.paymentprocessor.subscribers + +import arrow.core.Try +import arrow.core.getOrElse +import com.google.cloud.pubsub.v1.AckReplyConsumer +import com.google.protobuf.ByteString +import com.stripe.model.Event +import com.stripe.net.ApiResource.GSON +import org.ostelco.prime.getLogger +import org.ostelco.prime.paymentprocessor.ConfigRegistry +import org.ostelco.prime.pubsub.PubSubSubscriber + +class ReportStripeEvent : PubSubSubscriber( + subscription = ConfigRegistry.config.stripeEventReportSubscriptionId, + topic = ConfigRegistry.config.stripeEventTopicId, + project = ConfigRegistry.config.projectId) { + + private val logger by getLogger() + + override fun handler(message: ByteString, consumer: AckReplyConsumer) = + Try { + GSON.fromJson(message.toStringUtf8(), Event::class.java) + }.fold( + ifSuccess = { event -> + Try { + Reporter.report(event) + }.getOrElse { + logger.error("Attempt to log Stripe event {} failed with error message: {}", + message.toStringUtf8(), it) + } + consumer.ack() + }, + ifFailure = { + logger.error("Failed to decode Stripe event for logging and error reporting: {}", + it) + /* If unparsable JSON then this should not affect + upstream, as the message is invalid. */ + consumer.ack() + } + ) +} \ No newline at end of file diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/Reporter.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/Reporter.kt new file mode 100644 index 000000000..a7371943b --- /dev/null +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/Reporter.kt @@ -0,0 +1,216 @@ +package org.ostelco.prime.paymentprocessor.subscribers + +import com.stripe.model.* +import org.ostelco.prime.getLogger +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +object Reporter { + + private val logger by getLogger() + + fun report(event: Event) { + val data = event.data.`object` + + when (data) { + is Balance -> report(event, data) + is Card -> report(event, data) + is Charge -> report(event, data) + is Customer -> report(event, data) + is Dispute -> report(event, data) + is Invoice -> report(event, data) + is Payout -> report(event, data) + is Plan -> report(event, data) + is Product -> report(event, data) + is Refund -> report(event, data) + is Source -> report(event, data) + is Subscription -> report(event, data) + else -> { + logger.warn(format("No handler found for Stripe event ${event.type}", + event)) + } + } + } + + private fun report(event: Event, balance: Balance) { + when { + event.type == "balance.available" -> logger.info( + format("Your balance has new available transactions" + + "${currency(balance.available[0].amount, balance.available[0].currency)} is available, " + + "${currency(balance.pending[0].amount, balance.pending[0].currency)} is pending.", + event) + ) + else -> logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Balance)", + event)) + } + } + + private fun report(event: Event, card: Card) { + when { + event.type == "customer.source.created" -> logger.info( + format("${email(card.customer)} added a new ${card.brand} ending in ${card.last4}", + event) + ) + event.type == "customer.source.deleted" -> logger.info( + format("${email(card.customer)} deleted a ${card.brand} ending in ${card.last4}", + event) + ) + else -> logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Card)", + event)) + } + } + + private fun report(event: Event, charge: Charge) { + when { + event.type == "charge.captured" -> logger.info( + format("${email(charge.customer)}'s payment was captured for ${currency(charge.amount, charge.currency)}", + event) + ) + event.type == "charge.succeeded" -> logger.info( + format("An uncaptured payment for ${currency(charge.amount, charge.currency)} was created for ${email(charge.customer)}", + event) + ) + event.type == "charge.refunded" -> logger.info( + format("A ${currency(charge.amount, charge.currency)} payment was refunded to ${email(charge.customer)}}", + event) + ) + else -> logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Charge)", + event)) + } + } + + private fun report(event: Event, customer: Customer) { + when { + event.type == "customer.created" -> logger.info( + format("${customer.email} is a new customer", + event) + ) + event.type == "customer.deleted" -> logger.info( + format("${customer.email} had been deleted", + event) + ) + event.type == "customer.updated" -> logger.info( + format("${customer.email}'s details where updated", + event) + ) + else -> logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Customer)", + event)) + } + } + + private fun report(event: Event, dispute: Dispute) { + logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Dispute)", + event)) + } + + private fun report(event: Event, payout: Payout) { + when { + event.type == "payout.created" -> logger.info( + format("A new payout for ${currency(payout.amount, payout.currency)} was created and will be deposited " + + "on ${millisToDate(payout.arrivalDate)}", + event) + ) + event.type == "payout.paid" -> logger.info( + format("A payout of ${currency(payout.amount, payout.currency)} should now appear on your bank account statement", + event) + ) + else -> logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Payout)", + event)) + } + } + + private fun report(event: Event, invoice: Invoice) { + when { + event.type == "invoice.payment_succeeded" -> { + logger.info(format("An invoice ${invoice.id} for the amount of ${invoice.amountPaid} ${invoice.currency} " + + "was successfully charged to ${invoice.subscription}", + event)) + } + else -> logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Invoice)", + event)) + } + } + + private fun report(event: Event, plan: Plan) { + logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Plan)", + event)) + } + + private fun report(event: Event, product: Product) { + logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Product)", + event)) + } + + private fun report(event: Event, refund: Refund) { + logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Refund)", + event)) + } + + private fun report(event: Event, source: Source) { + when { + event.type == "customer.source.created" -> logger.info( + format("${email(source.customer)} added a new payment source", + event) + ) + event.type == "customer.source.deleted" -> logger.info( + format("Customer ${email(source.customer)} deleted a payment source", + event) + ) + event.type == "source.chargeable" -> logger.info( + format("A source with ID ${source.id} is chargeable", + event) + ) + else -> logger.warn( + "Unhandled Stripe event ${event.type} (cat: Source)" + + url(event.id)) + } + } + + private fun report(event: Event, subscription: Subscription) { + logger.warn(format("Unhandled Stripe event ${event.type} (cat: Subscription)", + event)) + } + + private fun format(s: String, event: Event): String { + return "${s} ${url(event.id)}" + } + + private fun url(eventId: String): String { + return "https://dashboard.stripe.com/events/${eventId}" + } + + private fun currency(amount: Long, currency: String): String { + return when (currency.toUpperCase()) { + "SGD", "USD" -> "\$" + else -> "" + } + "${amount / 100} ${currency.toUpperCase()}" + } + + private fun email(customerId: String?): String { + val email = if (!customerId.isNullOrEmpty()) + Customer.retrieve(customerId).email + else + null + return if (email.isNullOrEmpty()) "****" else email + } + + /* Convert millis since epoch to the date in YYYY-MM-DD format. + Assumes millis to be UTC. */ + private fun millisToDate(ts: Long): String { + val utc = LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), + ZoneOffset.UTC) + return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(utc) + } +} \ No newline at end of file diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/StoreStripeEvent.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/StoreStripeEvent.kt new file mode 100644 index 000000000..5396f639a --- /dev/null +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/StoreStripeEvent.kt @@ -0,0 +1,54 @@ +package org.ostelco.prime.paymentprocessor.subscribers + +import arrow.core.Try +import com.google.cloud.pubsub.v1.AckReplyConsumer +import com.google.protobuf.ByteString +import com.stripe.model.Event +import com.stripe.net.ApiResource.GSON +import org.ostelco.prime.getLogger +import org.ostelco.prime.paymentprocessor.ConfigRegistry +import org.ostelco.prime.pubsub.PubSubSubscriber +import org.ostelco.prime.store.datastore.DatastoreExcludeFromIndex +import org.ostelco.prime.store.datastore.EntityStore + + +class StoreStripeEvent : PubSubSubscriber( + subscription = ConfigRegistry.config.stripeEventStoreSubscriptionId, + topic = ConfigRegistry.config.stripeEventTopicId, + project = ConfigRegistry.config.projectId) { + + private val logger by getLogger() + + /* GCP datastore. */ + private val entityStore = EntityStore(StripeEvent::class.java, + type = ConfigRegistry.config.stripeEventStoreType, + namespace = ConfigRegistry.config.namespace) + + override fun handler(message: ByteString, consumer: AckReplyConsumer) = + Try { + GSON.fromJson(message.toStringUtf8(), Event::class.java) + }.fold( + ifSuccess = { event -> + entityStore.add(StripeEvent(event.type, + event.account, + event.created, + message.toStringUtf8())) + .mapLeft { + logger.error("Failed to store Stripe event {}: {}", + event.id, it) + } + consumer.ack() + }, + ifFailure = { + logger.error("Failed to decode Stripe event for logging and error reporting: {}", + it) + consumer.ack() + } + ) +} + +data class StripeEvent(val type: String, + val account: String?, + val created: Long, + @DatastoreExcludeFromIndex + val json: String) diff --git a/payment-processor/src/test/kotlin/org/ostelco/prime/paymentprocessor/StripeEventStoreTest.kt b/payment-processor/src/test/kotlin/org/ostelco/prime/paymentprocessor/StripeEventStoreTest.kt new file mode 100644 index 000000000..62479f366 --- /dev/null +++ b/payment-processor/src/test/kotlin/org/ostelco/prime/paymentprocessor/StripeEventStoreTest.kt @@ -0,0 +1,148 @@ +package org.ostelco.prime.paymentprocessor + +import arrow.core.getOrElse +import com.google.cloud.datastore.StringValue +import com.stripe.model.Event +import com.stripe.net.ApiResource +import org.junit.Test +import org.ostelco.prime.paymentprocessor.subscribers.StripeEvent +import org.ostelco.prime.store.datastore.EntityStore +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + + +class StripeEventStoreTest { + + @Test + fun `test save and fetch stripe event from datastore`() { + val event = ApiResource.GSON.fromJson(payload, Event::class.java) + val testData = StripeEvent(event.type, + event.account, + event.created, + payload) + val key = entityStore.add(testData).getOrElse { null } + assertNotNull(key) + + val fetched = entityStore.fetch(key).getOrElse { null } + assertNotNull(fetched) + assertEquals(expected = testData, actual = fetched) + } + + companion object { + val entityStore = EntityStore(StripeEvent::class.java, + type = "inmemory-emulator", + namespace = "test") + val payload = """ + { + "id": "charge.captured_00000000000000", + "object": "event", + "account": "null", + "api_version": "2018-08-23", + "created": 1326853478, + "data": { + "object": { + "id": "ch_00000000000000", + "object": "charge", + "amount": 100, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "alternate_statement_descriptors": null, + "balance_transaction": "txn_00000000000000", + "captured": true, + "created": 1554102110, + "currency": "nok", + "customer": null, + "description": "My First Test Charge (created for API docs)", + "destination": null, + "dispute": null, + "failure_code": null, + "failure_message": null, + "fraud_details": { + "user_report": null, + "stripe_report": null + }, + "invoice": null, + "level3": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": null, + "paid": true, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_1D3iWUIsscchPQo7/ch_1EKK9SIsE3jhPQo7aaavbFUx/rcpt_Enw1fqDWFhlAT3aZmk8E0G5kds7AKGr", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_1EKK9SIsE3jhPuua34nvbFUx/refunds", + "count": null, + "request_options": null, + "request_params": null + }, + "review": null, + "shipping": null, + "source": { + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "available_payout_methods": null, + "brand": "Visa", + "country": "US", + "currency": null, + "cvc_check": "pass", + "default_for_currency": null, + "dynamic_last4": null, + "exp_month": 8, + "exp_year": 2019, + "fingerprint": "0iYTxuneb6pNlFWV", + "funding": "credit", + "last4": "4242", + "name": null, + "recipient": null, + "status": null, + "three_d_secure": null, + "tokenization_method": null, + "deleted": null, + "description": null, + "iin": null, + "issuer": null, + "type": null, + "id": "card_00000000000000", + "object": "card", + "account": null, + "customer": "cus_00000000000000", + "metadata": {} + }, + "source_transfer": null, + "statement_descriptor": null, + "status": "succeeded", + "transfer": null, + "transfer_data": null, + "transfer_group": null, + "authorization_code": null, + "card": null, + "disputed": null, + "statement_description": null + }, + "previous_attributes": null + }, + "livemode": false, + "pending_webhooks": 1, + "request": null, + "type": "charge.captured", + "user_id": null + } + """.trimIndent() + } +} \ No newline at end of file diff --git a/prime-client-api/README.md b/prime-client-api/README.md deleted file mode 100644 index 72dc73694..000000000 --- a/prime-client-api/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Build java client for prime api - - gradle swagger - -will build the java code. build.gradle not yet extended to build the -actual library jar and install it in the appropriate locations. diff --git a/prime-client-api/build.gradle b/prime-client-api/build.gradle deleted file mode 100644 index c043500ca..000000000 --- a/prime-client-api/build.gradle +++ /dev/null @@ -1,67 +0,0 @@ -plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" - id 'java-library' - id 'org.hidetake.swagger.generator' version '2.14.0' - id "idea" -} - -// gradle generateSwaggerCode -swaggerSources { - 'java-client' { - inputFile = file("${projectDir}/../prime/infra/dev/prime-client-api.yaml") - code { - language = 'java' - configFile = file("${projectDir}/config.json") -// components = ["models"] - } - } -} - -compileJava.dependsOn swaggerSources.'java-client'.code -sourceSets.main.java.srcDir "${swaggerSources.'java-client'.code.outputDir}/src/main/java" -sourceSets.main.resources.srcDir "${swaggerSources.'java-client'.code.outputDir}/src/main/resources" - -// if they ever fix kotlin -//swaggerSources { -// 'kotlin-client' { -// inputFile = file("${projectDir}/../prime/infra/prod/prime-client-api.yaml") -// code { -// language = 'kotlin' -// configFile = file("${projectDir}/config.json") -// } -// } -//} -// -//compileKotlin.dependsOn swaggerSources.'kotlin-client'.code -//sourceSets.main.kotlin.srcDir "${swaggerSources.'kotlin-client'.code.outputDir}/src/main/java" -//sourceSets.main.resources.srcDir "${swaggerSources.'kotlin-client'.code.outputDir}/src/main/resources" - - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - swaggerCodegen 'io.swagger:swagger-codegen-cli:2.3.1' - - implementation 'javax.annotation:javax.annotation-api:1.3.2' - - // taken from build/swagger-code-java-client/build.gradle - implementation 'io.swagger:swagger-annotations:1.5.21' - implementation 'com.google.code.gson:gson:2.8.5' - implementation 'com.squareup.okhttp:okhttp:2.7.5' - implementation 'com.squareup.okhttp:logging-interceptor:2.7.5' - implementation 'io.gsonfire:gson-fire:1.8.3' - implementation 'org.threeten:threetenbp:1.3.7' - testImplementation 'junit:junit:4.12' - - // taken from build/swagger-code-kotlin-client/build.gradle -// implementation "com.squareup.okhttp3:okhttp:3.8.0" -// implementation "com.squareup.moshi:moshi-kotlin:1.5.0" -// implementation "com.squareup.moshi:moshi-adapters:1.5.0" -} - -idea { - module { - // generatedSourceDirs - sourceDirs += files("${project.buildDir.path}/swagger-code-java-client/src/main/java") -// sourceDirs += files("${project.buildDir.path}/swagger-code-kotlin-client/src/main/kotlin") - } -} \ No newline at end of file diff --git a/prime-client-api/config.json b/prime-client-api/config.json deleted file mode 100644 index f8ce668cb..000000000 --- a/prime-client-api/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "invokerPackage": "org.ostelco.prime.client", - "modelPackage": "org.ostelco.prime.client.model", - "apiPackage": "org.ostelco.prime.client.api" -} \ No newline at end of file diff --git a/prime-customer-api/build.gradle b/prime-customer-api/build.gradle new file mode 100644 index 000000000..1d92c48bc --- /dev/null +++ b/prime-customer-api/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'java-library' + id 'org.hidetake.swagger.generator' version '2.18.1' + id "idea" +} + +// gradle generateSwaggerCode +swaggerSources { + 'java-client' { + inputFile = file("${projectDir}/../prime/infra/dev/prime-customer-api.yaml") + code { + language = 'java' + configFile = file("${projectDir}/config.json") + } + } +} + +compileJava.dependsOn swaggerSources.'java-client'.code +sourceSets.main.java.srcDir "${swaggerSources.'java-client'.code.outputDir}/src/main/java" +sourceSets.main.resources.srcDir "${swaggerSources.'java-client'.code.outputDir}/src/main/resources" + +dependencies { + swaggerCodegen "io.swagger:swagger-codegen-cli:$swaggerCodegenVersion" + + implementation 'javax.annotation:javax.annotation-api:1.3.2' + + // taken from build/swagger-code-java-client/build.gradle + implementation 'io.swagger:swagger-annotations:1.5.22' + implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.squareup.okhttp:okhttp:2.7.5' + implementation 'com.squareup.okhttp:logging-interceptor:2.7.5' + implementation 'io.gsonfire:gson-fire:1.8.3' + implementation 'org.threeten:threetenbp:1.3.8' + testImplementation 'junit:junit:4.12' +} + +idea { + module { + // generatedSourceDirs + sourceDirs += files("${project.buildDir.path}/swagger-code-java-client/src/main/java") + } +} \ No newline at end of file diff --git a/prime-customer-api/config.json b/prime-customer-api/config.json new file mode 100644 index 000000000..0f360d9f0 --- /dev/null +++ b/prime-customer-api/config.json @@ -0,0 +1,5 @@ +{ + "invokerPackage": "org.ostelco.prime.customer", + "modelPackage": "org.ostelco.prime.customer.model", + "apiPackage": "org.ostelco.prime.customer.api" +} \ No newline at end of file diff --git a/prime-modules/build.gradle b/prime-modules/build.gradle index 559ba42f4..b5013831e 100644 --- a/prime-modules/build.gradle +++ b/prime-modules/build.gradle @@ -1,5 +1,5 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } @@ -7,7 +7,10 @@ dependencies { api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" api "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" - api "io.jsonwebtoken:jjwt:0.9.1" + api "io.dropwizard:dropwizard-auth:$dropwizardVersion" + implementation "com.google.cloud:google-cloud-pubsub:$googleCloudVersion" + implementation "com.google.cloud:google-cloud-datastore:$googleCloudVersion" + api "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" api project(':ocs-grpc-api') @@ -15,5 +18,17 @@ dependencies { api project(':model') api "io.dropwizard:dropwizard-core:$dropwizardVersion" - api "io.arrow-kt:arrow-core:0.7.3" -} \ No newline at end of file + + api "io.arrow-kt:arrow-core:$arrowVersion" + api "io.arrow-kt:arrow-typeclasses:$arrowVersion" + api "io.arrow-kt:arrow-instances-core:$arrowVersion" + api "io.arrow-kt:arrow-effects:$arrowVersion" + + runtimeOnly "javax.xml.bind:jaxb-api:$jaxbVersion" + runtimeOnly "javax.activation:activation:$javaxActivationVersion" + + testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsService.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsService.kt index ee2ff6a1d..1959a63c4 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsService.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/analytics/AnalyticsService.kt @@ -4,9 +4,9 @@ import org.ostelco.prime.analytics.MetricType.GAUGE import org.ostelco.prime.model.PurchaseRecord interface AnalyticsService { - fun reportTrafficInfo(msisdn: String, usedBytes: Long, bundleBytes: Long, apn: String?, mccMnc: String?) + fun reportTrafficInfo(msisdnAnalyticsId: String, usedBytes: Long, bundleBytes: Long, apn: String?, mccMnc: String?) fun reportMetric(primeMetric: PrimeMetric, value: Long) - fun reportPurchaseInfo(purchaseRecord: PurchaseRecord, subscriberId: String, status: String) + fun reportPurchaseInfo(purchaseRecord: PurchaseRecord, customerAnalyticsId: String, status: String) } enum class PrimeMetric(val metricType: MetricType) { diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt index 5f2ef2032..7c5b1f7a9 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt @@ -3,6 +3,7 @@ package org.ostelco.prime.apierror import org.ostelco.prime.getLogger import org.ostelco.prime.jsonmapper.asJson import org.ostelco.prime.paymentprocessor.core.PaymentError +import org.ostelco.prime.simmanager.SimManagerError import org.ostelco.prime.storage.StoreError import javax.ws.rs.core.Response @@ -10,8 +11,8 @@ sealed class ApiError(val message: String, val errorCode: ApiErrorCode, val erro open var status : Int = 0 } -class BadGatewayError(description: String, errorCode: ApiErrorCode, error: InternalError? = null) : ApiError(description, errorCode, error) { - override var status : Int = Response.Status.BAD_GATEWAY.statusCode +class InternalServerError(description: String, errorCode: ApiErrorCode, error: InternalError? = null) : ApiError(description, errorCode, error) { + override var status : Int = Response.Status.INTERNAL_SERVER_ERROR.statusCode } class BadRequestError(description: String, errorCode: ApiErrorCode, error: InternalError? = null) : ApiError(description, errorCode, error) { @@ -22,10 +23,6 @@ class ForbiddenError(description: String, errorCode: ApiErrorCode, error: Intern override var status : Int = Response.Status.FORBIDDEN.statusCode } -class InsufficientStorageError(description: String, errorCode: ApiErrorCode, error: InternalError? = null) : ApiError(description, errorCode, error) { - override var status : Int = 507 -} - class NotFoundError(description: String, errorCode: ApiErrorCode, error: InternalError? = null) : ApiError(description, errorCode, error) { override var status : Int = Response.Status.NOT_FOUND.statusCode } @@ -39,7 +36,7 @@ object ApiErrorMapper { return when(paymentError) { is org.ostelco.prime.paymentprocessor.core.ForbiddenError -> org.ostelco.prime.apierror.ForbiddenError(description, errorCode, paymentError) // FIXME vihang: remove PaymentError from BadGatewayError - is org.ostelco.prime.paymentprocessor.core.BadGatewayError -> org.ostelco.prime.apierror.BadGatewayError(description, errorCode, paymentError) + is org.ostelco.prime.paymentprocessor.core.BadGatewayError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode, paymentError) is org.ostelco.prime.paymentprocessor.core.NotFoundError -> org.ostelco.prime.apierror.NotFoundError(description, errorCode, paymentError) } } @@ -50,10 +47,26 @@ object ApiErrorMapper { is org.ostelco.prime.storage.NotFoundError -> org.ostelco.prime.apierror.NotFoundError(description, errorCode, storeError) is org.ostelco.prime.storage.AlreadyExistsError -> org.ostelco.prime.apierror.ForbiddenError(description, errorCode, storeError) // FIXME vihang: remove StoreError from BadGatewayError - is org.ostelco.prime.storage.NotCreatedError -> org.ostelco.prime.apierror.BadGatewayError(description, errorCode) - is org.ostelco.prime.storage.NotUpdatedError -> org.ostelco.prime.apierror.BadGatewayError(description, errorCode) - is org.ostelco.prime.storage.NotDeletedError -> org.ostelco.prime.apierror.BadGatewayError(description, errorCode) + is org.ostelco.prime.storage.NotCreatedError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode) + is org.ostelco.prime.storage.NotUpdatedError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode) + is org.ostelco.prime.storage.NotDeletedError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode) is org.ostelco.prime.storage.ValidationError -> org.ostelco.prime.apierror.ForbiddenError(description, errorCode, storeError) + is org.ostelco.prime.storage.FileDownloadError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode) + is org.ostelco.prime.storage.FileDeleteError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode) + is org.ostelco.prime.storage.SystemError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode) + is org.ostelco.prime.storage.DatabaseError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode) + } + } + + fun mapSimManagerErrorToApiError(description: String, errorCode: ApiErrorCode, simManagerError: SimManagerError) : ApiError { + logger.error("description: $description, errorCode: $errorCode, simManagerError: ${asJson(simManagerError)}") + return when (simManagerError) { + is org.ostelco.prime.simmanager.NotFoundError -> org.ostelco.prime.apierror.NotFoundError(description, errorCode, simManagerError) + is org.ostelco.prime.simmanager.NotUpdatedError -> org.ostelco.prime.apierror.BadRequestError(description, errorCode, simManagerError) + is org.ostelco.prime.simmanager.ForbiddenError -> org.ostelco.prime.apierror.ForbiddenError(description, errorCode, simManagerError) + is org.ostelco.prime.simmanager.AdapterError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode, simManagerError) + is org.ostelco.prime.simmanager.DatabaseError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode, simManagerError) + is org.ostelco.prime.simmanager.SystemError -> org.ostelco.prime.apierror.InternalServerError(description, errorCode, simManagerError) } } -} \ No newline at end of file +} diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt index 95e438d7c..b32c4d2c5 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt @@ -1,26 +1,86 @@ package org.ostelco.prime.apierror enum class ApiErrorCode { - FAILED_TO_CREATE_PAYMENT_PROFILE, + + // payment + // FAILED_TO_CREATE_PAYMENT_PROFILE, FAILED_TO_FETCH_PAYMENT_PROFILE, + FAILED_TO_FETCH_PAYMENT_HISTORY, + FAILED_TO_GENERATE_STRIPE_EPHEMERAL_KEY, + + // payment source + FAILED_TO_STORE_PAYMENT_SOURCE, + FAILED_TO_SET_DEFAULT_PAYMENT_SOURCE, + FAILED_TO_FETCH_PAYMENT_SOURCES_LIST, + FAILED_TO_REMOVE_PAYMENT_SOURCE, + + // notification FAILED_TO_STORE_APPLICATION_TOKEN, - @Deprecated("Will be removed") - FAILED_TO_FETCH_SUBSCRIPTION_STATUS, - FAILED_TO_FETCH_SUBSCRIPTIONS, + + // bundles FAILED_TO_FETCH_BUNDLES, - FAILED_TO_FETCH_PSEUDONYM_FOR_SUBSCRIBER, - FAILED_TO_FETCH_PAYMENT_HISTORY, + + // products FAILED_TO_FETCH_PRODUCT_LIST, + FAILED_TO_FETCH_PRODUCT_INFORMATION, + + // purchase FAILED_TO_PURCHASE_PRODUCT, + FAILED_TO_REFUND_PURCHASE, + + // referral FAILED_TO_FETCH_REFERRALS, FAILED_TO_FETCH_REFERRED_BY_LIST, - FAILED_TO_FETCH_PRODUCT_INFORMATION, - FAILED_TO_STORE_PAYMENT_SOURCE, - FAILED_TO_SET_DEFAULT_PAYMENT_SOURCE, - FAILED_TO_FETCH_PAYMENT_SOURCES_LIST, - FAILED_TO_REMOVE_PAYMENT_SOURCE, - FAILED_TO_CREATE_PROFILE, - FAILED_TO_UPDATE_PROFILE, - FAILED_TO_FETCH_CONSENT, + + // customer + FAILED_TO_FETCH_CUSTOMER_ID, + FAILED_TO_FETCH_CUSTOMER, + FAILED_TO_CREATE_CUSTOMER, + FAILED_TO_UPDATE_CUSTOMER, + FAILED_TO_REMOVE_CUSTOMER, + + // context + FAILED_TO_FETCH_CONTEXT, + + // offer FAILED_TO_IMPORT_OFFER, -} \ No newline at end of file + + // plan + FAILED_TO_FETCH_PLAN, + FAILED_TO_FETCH_PLANS_FOR_SUBSCRIBER, + FAILED_TO_STORE_PLAN, + FAILED_TO_REMOVE_PLAN, + // FAILED_TO_SUBSCRIBE_TO_PLAN, + // FAILED_TO_RECORD_PLAN_INVOICE, + + // subscription + FAILED_TO_FETCH_SUBSCRIPTIONS, + FAILED_TO_CREATE_SUBSCRIPTION, + FAILED_TO_STORE_SUBSCRIPTION, + FAILED_TO_REMOVE_SUBSCRIPTION, + + // SIM profile + FAILED_TO_FETCH_SIM_PROFILES, + FAILED_TO_PROVISION_SIM_PROFILE, + + // SIM Admin + FAILED_TO_FETCH_SIM_PROFILE, + FAILED_TO_RESERVE_ACTIVATED_SIM_PROFILE, + FAILED_TO_ACTIVATE_SIM_PROFILE, + FAILED_TO_ACTIVATE_SIM_PROFILE_WITH_HLR, + FAILED_TO_DEACTIVATE_SIM_PROFILE_WITH_HLR, + FAILED_TO_IMPORT_BATCH, + + // Jumio + FAILED_TO_CREATE_SCANID, + FAILED_TO_FETCH_SCAN_INFORMATION, + FAILED_TO_UPDATE_SCAN_RESULTS, + + FAILED_TO_FETCH_SUBSCRIBER_STATE, + FAILED_TO_FETCH_REGIONS, + + // eKYC - Singapore + FAILED_TO_FETCH_CUSTOMER_MYINFO_DATA, + INVALID_NRIC_FIN_ID, + FAILED_TO_SAVE_ADDRESS_AND_PHONE_NUMBER, +} diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt index 95bac1885..95b110ecd 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt @@ -1,5 +1,6 @@ package org.ostelco.prime.appnotifier interface AppNotifier { - fun notify(msisdn: String, title: String, body: String) + fun notify(customerId: String, title: String, body: String) + fun notify(customerId: String, title: String, body: String, data: Map) } diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/auth/AccessTokenPrincipal.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/auth/AccessTokenPrincipal.kt new file mode 100644 index 000000000..76baed31f --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/auth/AccessTokenPrincipal.kt @@ -0,0 +1,11 @@ +package org.ostelco.prime.auth + +import java.security.Principal + +/** + * Holds the 'user-id' obtained by verifying and decoding an OAuth2 + * 'access-token'. + */ +class AccessTokenPrincipal(private val email: String, val provider: String) : Principal { + override fun getName(): String = email +} diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/auth/OAuthAuthenticator.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/auth/OAuthAuthenticator.kt similarity index 51% rename from client-api/src/main/kotlin/org/ostelco/prime/client/api/auth/OAuthAuthenticator.kt rename to prime-modules/src/main/kotlin/org/ostelco/prime/auth/OAuthAuthenticator.kt index 8b4d7f50b..bc95a5c43 100644 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/auth/OAuthAuthenticator.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/auth/OAuthAuthenticator.kt @@ -1,12 +1,11 @@ -package org.ostelco.prime.client.api.auth +package org.ostelco.prime.auth import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.dropwizard.auth.AuthenticationException import io.dropwizard.auth.Authenticator -import org.ostelco.prime.client.api.core.UserInfo import org.ostelco.prime.getLogger +import org.ostelco.prime.jsonmapper.objectMapper import java.io.IOException import java.util.* import javax.ws.rs.client.Client @@ -23,34 +22,80 @@ import javax.ws.rs.core.Response * https://www.dropwizard.io/1.3.2/docs/manual/auth.html#oauth2 */ -private const val DEFAULT_USER_INFO_ENDPOINT = "https://ostelco.eu.auth0.com/userinfo" +private const val DEFAULT_USER_INFO_ENDPOINT = "https://auth.oya.world/userinfo" +private const val NAMESPACE = "https://ostelco" class OAuthAuthenticator(private val client: Client) : Authenticator { private val logger by getLogger() - private val mapper = jacksonObjectMapper() - override fun authenticate(accessToken: String): Optional { + try { + val claims = getClaims(accessToken) + if (claims != null) { + return when { + isFirebase(claims) -> FirebaseAuthenticator(claims).authenticate() + else -> Auth0Authenticator(client, claims).authenticate(accessToken) + } + } + } catch (e: Exception) { + logger.error("Could not parse claims from the 'access-token'", e) + } + return Optional.empty() + } - var userInfoEndpoint: String + /* Extracts 'claims' part from JWT token. + Throws 'illegalargumentexception' exception on error. */ + private fun getClaims(token: String): JsonNode? { + if (token.codePoints().filter { ch -> ch == '.'.toInt() }.count() != 2L) { + throw java.lang.IllegalArgumentException("The provided token is an Invalid JWT token") + } + val parts = token.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + return String(Base64.getDecoder().decode(parts[1] + .replace("-", "+") + .replace("_", "/"))) + .let(::decodeClaims) + } + /* Decodes the claims part of a JWT token. + Returns null on error. */ + private fun decodeClaims(claims: String): JsonNode? { try { - val claims = decodeClaims(getClaims(accessToken)) - userInfoEndpoint = getUserInfoEndpointFromAudience(claims) - } catch (e: Exception) { - logger.error("No audience field in the 'access-token' claims part", e) - userInfoEndpoint = DEFAULT_USER_INFO_ENDPOINT + return objectMapper.readTree(claims) + } catch (e: JsonParseException) { + logger.error("Parsing of the provided json doc {} failed: {}", claims, e) + } catch (e: IOException) { + logger.error("Unexpected error when parsing the json doc {}: {}", claims, e) + } + return null + } + + private fun isFirebase(claims: JsonNode): Boolean { + return claims.has("firebase") + } +} + +class Auth0Authenticator(private val client: Client, private val claims: JsonNode) { + private val logger by getLogger() + + fun authenticate(accessToken: String): Optional { + + var email = getEmail(claims) + val provider = getSubjectPrefix(claims) + if (email != null) { + return Optional.of(AccessTokenPrincipal(email = email, provider = provider)) } + val userInfoEndpoint = getUserInfoEndpointFromAudience(claims) val userInfo = getUserInfo(userInfoEndpoint, accessToken) - val email = userInfo.email + email = userInfo.email if (email == null || email.isEmpty()) { logger.warn("email is missing in userInfo") return Optional.empty() } - return Optional.of(AccessTokenPrincipal(email)) + return Optional.of(AccessTokenPrincipal(email = email, provider = provider)) } private fun getUserInfo(userInfoEndpoint: String, accessToken: String): UserInfo { @@ -79,9 +124,9 @@ class OAuthAuthenticator(private val client: Client) : Authenticator ch == '.'.toInt() }.count() != 2L) { - throw IllegalArgumentException("The provided token is an Invalid JWT token") + private fun getEmail(claims: JsonNode): String? { + return if (claims.has("$NAMESPACE/email")) { + claims.get("$NAMESPACE/email").textValue() + } else { + logger.error("Missing '{}/email' field in claims part of JWT token {}", + NAMESPACE, claims) + null } - val parts = token.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + } - return String(Base64.getDecoder().decode(parts[1] - .replace("-", "+") - .replace("_", "/"))) + private fun getSubjectPrefix(claims: JsonNode): String { + return if (claims.has("sub")) { + claims.get("sub").textValue().split('|').first() + } else { + logger.error("Missing 'sub' field in claims part of JWT token {}", claims) + "" + } } - /* Decodes the claims part of a JWT token. - Returns null on error. */ - private fun decodeClaims(claims: String): JsonNode? { - var obj: JsonNode? = null - try { - obj = mapper.readTree(claims) - } catch (e: JsonParseException) { - logger.error("Parsing of the provided json doc {} failed: {}", claims, e) - } catch (e: IOException) { - logger.error("Unexpected error when parsing the json doc {}: {}", claims, e) +} + +class FirebaseAuthenticator(private val claims: JsonNode) { + private val logger by getLogger() + + fun authenticate(): Optional { + val email = getEmail(claims) + val provider = getProvider(claims) + if (email == null || email.isEmpty()) { + logger.warn("email is missing in userInfo") + return Optional.empty() + } else { + return Optional.of(AccessTokenPrincipal(email = email, provider = provider)) + } + } + + private fun getEmail(claims: JsonNode): String? { + return when { + claims.path("firebase").path("identities").has("email") -> { + val emailList = claims.path("firebase").path("identities").get("email") + emailList.get(0).textValue() + } + else -> { + logger.error("Missing '{}/email' field in claims part of JWT token {}", + NAMESPACE, claims) + null + } + } + } + + private fun getProvider(claims: JsonNode): String { + return when { + claims.path("firebase").has("sign_in_provider") -> claims.path("firebase").get("sign_in_provider").textValue() + else -> { + logger.error("Unsupported firebase type", NAMESPACE, claims) + "firebase" + } } - return obj } } diff --git a/client-api/src/main/kotlin/org/ostelco/prime/client/api/core/UserInfo.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/auth/UserInfo.kt similarity index 94% rename from client-api/src/main/kotlin/org/ostelco/prime/client/api/core/UserInfo.kt rename to prime-modules/src/main/kotlin/org/ostelco/prime/auth/UserInfo.kt index b1da2bbe8..c0a66a584 100644 --- a/client-api/src/main/kotlin/org/ostelco/prime/client/api/core/UserInfo.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/auth/UserInfo.kt @@ -1,4 +1,4 @@ -package org.ostelco.prime.client.api.core +package org.ostelco.prime.auth /** * Captures the user info data (consented claims) fetched from the OAuth2 diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/ekyc/KycServices.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/ekyc/KycServices.kt new file mode 100644 index 000000000..7d8bdf684 --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/ekyc/KycServices.kt @@ -0,0 +1,9 @@ +package org.ostelco.prime.ekyc + +interface MyInfoKycService { + fun getPersonData(authorisationCode: String) : String +} + +interface DaveKycService { + fun validate(id: String?) : Boolean +} \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/jersey/client/HttpClientUtil.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/jersey/client/HttpClientUtil.kt new file mode 100644 index 000000000..153d77f2e --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/jersey/client/HttpClientUtil.kt @@ -0,0 +1,96 @@ +package org.ostelco.prime.jersey.client + +import org.glassfish.jersey.client.JerseyClientBuilder +import org.glassfish.jersey.client.JerseyInvocation +import org.glassfish.jersey.client.JerseyWebTarget +import org.ostelco.prime.getLogger +import org.ostelco.prime.jersey.client.HttpClient.logger +import javax.ws.rs.client.Entity +import javax.ws.rs.core.GenericType +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.MultivaluedHashMap + +/** + * Class to hold configuration which is set in DSL functions. + */ +class HttpRequest { + lateinit var target: String + lateinit var path: String + var headerParams: Map> = emptyMap() + var queryParams: Map = emptyMap() + var body: Any? = null +} + +/** + * DSL function for GET operation + */ +inline fun get(execute: HttpRequest.() -> Unit): T { + val request = HttpRequest().apply(execute) + val response = HttpClient.send(request.target, request.path, request.queryParams, request.headerParams).get() + if (200 != response.status) { + logger.warn(response.readEntity(String::class.java)) + } + return response.readEntity(object : GenericType() {}) +} + +/** + * DSL function for POST operation + */ +inline fun post(expectedResultCode: Int = 201, dataType: MediaType = MediaType.APPLICATION_JSON_TYPE, execute: HttpRequest.() -> Unit): T { + val request = HttpRequest().apply(execute) + val response = HttpClient.send(request.target, request.path, request.queryParams, request.headerParams) + .post(Entity.entity(request.body ?: "", dataType)) + if (expectedResultCode != response.status) { + logger.warn(response.readEntity(String::class.java)) + } + return response.readEntity(object : GenericType() {}) +} + +/** + * DSL function for PUT operation + */ +inline fun put(execute: HttpRequest.() -> Unit): T { + val request = HttpRequest().apply(execute) + val response = HttpClient.send(request.target, request.path, request.queryParams, request.headerParams) + .put(Entity.entity(request.body ?: "", MediaType.APPLICATION_JSON_TYPE)) + if (200 != response.status) { + logger.warn(response.readEntity(String::class.java)) + } + return response.readEntity(object : GenericType() {}) +} + +/** + * DSL function for DELETE operation + */ +inline fun delete(execute: HttpRequest.() -> Unit): T { + val request = HttpRequest().apply(execute) + val response = HttpClient.send(request.target, request.path, request.queryParams, request.headerParams) + .delete() + if (200 != response.status) { + logger.warn(response.readEntity(String::class.java)) + } + return response.readEntity(object : GenericType() {}) +} + +/** + * Class which holds JerseyClient. + * It is used by DSL functions to make actual HTTP Rest invocation. + */ +object HttpClient { + + private val jerseyClient = JerseyClientBuilder.createClient() + + val logger by getLogger() + + fun send( + target: String, + path: String, + queryParams: Map, + headerParams: Map>): JerseyInvocation.Builder { + + var jerseyWebTarget: JerseyWebTarget = jerseyClient.target(target).path(path) + queryParams.forEach { jerseyWebTarget = jerseyWebTarget.queryParam(it.key, it.value) } + return jerseyWebTarget.request(MediaType.APPLICATION_JSON_TYPE) + .headers(MultivaluedHashMap().apply { this.putAll(headerParams) }) + } +} diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/notifications/EmailNotifier.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/notifications/EmailNotifier.kt new file mode 100644 index 000000000..5d5c1baa0 --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/notifications/EmailNotifier.kt @@ -0,0 +1,7 @@ +package org.ostelco.prime.notifications + +import arrow.core.Either + +interface EmailNotifier { + fun sendESimQrCodeEmail(email: String, name: String, qrCode: String) : Either +} \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/ocs/OcsSubscriberService.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/ocs/OcsSubscriberService.kt index 492d6ff6b..c3770a9bb 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/ocs/OcsSubscriberService.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/ocs/OcsSubscriberService.kt @@ -2,12 +2,20 @@ package org.ostelco.prime.ocs import arrow.core.Either import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Identity +import kotlin.DeprecationLevel.ERROR +import kotlin.DeprecationLevel.WARNING +@Deprecated(message = "This service bas been discontinued", level = WARNING) interface OcsSubscriberService { - fun topup(subscriberId: String, sku: String): Either + @Deprecated(message = "This service bas been discontinued", level = ERROR) + fun topup(identity: Identity, sku: String): Either } +@Deprecated(message = "This service bas been discontinued", level = WARNING) interface OcsAdminService { + @Deprecated(message = "This service bas been discontinued", level = ERROR) fun addBundle(bundle: Bundle) + @Deprecated(message = "This service bas been discontinued", level = ERROR) fun addMsisdnToBundleMapping(msisdn: String, bundleId: String) } \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt index eda338db4..135ab76b8 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt @@ -1,7 +1,13 @@ package org.ostelco.prime.paymentprocessor import arrow.core.Either -import org.ostelco.prime.paymentprocessor.core.* +import org.ostelco.prime.paymentprocessor.core.PaymentError +import org.ostelco.prime.paymentprocessor.core.PlanInfo +import org.ostelco.prime.paymentprocessor.core.ProductInfo +import org.ostelco.prime.paymentprocessor.core.ProfileInfo +import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo +import org.ostelco.prime.paymentprocessor.core.SourceInfo +import org.ostelco.prime.paymentprocessor.core.SubscriptionInfo interface PaymentProcessor { @@ -13,51 +19,54 @@ interface PaymentProcessor { } /** - * @param customerId Stripe customer id - * @param sourceId Stripe source id + * @param stripeCustomerId Stripe customer id + * @param stripeSourceId Stripe source id * @return Stripe sourceId if created */ - fun addSource(customerId: String, sourceId: String): Either + fun addSource(stripeCustomerId: String, stripeSourceId: String): Either /** - * @param userEmail: user email (Prime unique identifier for customer) + * @param customerId: Prime unique identifier for customer + * @param email: Contact email address * @return Stripe customerId if created */ - fun createPaymentProfile(userEmail: String): Either + fun createPaymentProfile(customerId: String, email: String): Either /** - * @param customerId Stripe customer id + * @param stripeCustomerId Stripe customer id * @return Stripe customerId if deleted */ - fun deletePaymentProfile(customerId: String): Either + fun deletePaymentProfile(stripeCustomerId: String): Either /** - * @param userEmail: user email (Prime unique identifier for customer) + * @param customerId: user email (Prime unique identifier for customer) * @return Stripe customerId if exist */ - fun getPaymentProfile(userEmail: String): Either + fun getPaymentProfile(customerId: String): Either /** - * @param productId Stripe product id + * @param productId The product associated with the new plan * @param amount The amount to be charged in the interval specified * @param currency Three-letter ISO currency code in lowercase - * @param interval The frequency with which a subscription should be billed. - * @return Stripe planId if created + * @param interval The frequency with which a subscription should be billed + * @param invervalCount The number of intervals between subscription billings + * @return Stripe plan details */ - fun createPlan(productId: String, amount: Int, currency: String, interval: Interval): Either + fun createPlan(productId: String, amount: Int, currency: String, interval: Interval, intervalCount: Long = 1): Either /** * @param Stripe Plan Id - * @param Stripe Customer Id - * @return Stripe SubscriptionId if subscribed + * @return Stripe PlanId if deleted */ - fun subscribeToPlan(planId: String, customerId: String): Either + fun removePlan(planId: String): Either /** * @param Stripe Plan Id - * @return Stripe PlanId if deleted + * @param stripeCustomerId Stripe Customer Id + * @param Epoch timestamp for when the trial period ends + * @return Stripe SubscriptionId if subscribed */ - fun removePlan(planId: String): Either + fun createSubscription(planId: String, stripeCustomerId: String, trialEnd: Long = 0L): Either /** * @param Stripe Subscription Id @@ -79,23 +88,23 @@ interface PaymentProcessor { fun removeProduct(productId: String): Either /** - * @param customerId Stripe customer id + * @param stripeCustomerId Stripe customer id * @return List of Stripe sourceId */ - fun getSavedSources(customerId: String): Either> + fun getSavedSources(stripeCustomerId: String): Either> /** - * @param customerId Stripe customer id + * @param stripeCustomerId Stripe customer id * @return Stripe default sourceId */ - fun getDefaultSource(customerId: String): Either + fun getDefaultSource(stripeCustomerId: String): Either /** - * @param customerId Stripe customer id + * @param stripeCustomerId Stripe customer id * @param sourceId Stripe source id * @return SourceInfo if created */ - fun setDefaultSource(customerId: String, sourceId: String): Either + fun setDefaultSource(stripeCustomerId: String, sourceId: String): Either /** * @param customerId Customer id in the payment system @@ -111,18 +120,21 @@ interface PaymentProcessor { * @param customerId Customer id in the payment system * @return id of the charge if authorization was successful */ - fun captureCharge(chargeId: String, customerId: String): Either + fun captureCharge(chargeId: String, customerId: String, amount: Int, currency: String): Either /** * @param chargeId ID of the of the authorized charge to refund from authorizeCharge() * @return id of the charge */ - fun refundCharge(chargeId: String): Either + fun refundCharge(chargeId: String, amount: Int, currency: String + ): Either /** - * @param customerId Customer id in the payment system + * @param stripeCustomerId Customer id in the payment system * @param sourceId id of the payment source * @return id if removed */ - fun removeSource(customerId: String, sourceId: String): Either + fun removeSource(stripeCustomerId: String, sourceId: String): Either + + fun getStripeEphemeralKey(customerId: String, email: String, apiVersion: String): Either } \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt index 21c0b9301..ad30c4306 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt @@ -10,4 +10,4 @@ data class SourceInfo(val id: String) data class SourceDetailsInfo(val id: String, val type: String, val details: Map) -data class SubscriptionInfo(val id: String) +data class SubscriptionInfo(val id: String, val created: Long, val trialEnd: Long) diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt index ce3c7188a..cbea8af3c 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt @@ -2,10 +2,10 @@ package org.ostelco.prime.paymentprocessor.core import org.ostelco.prime.apierror.InternalError -sealed class PaymentError(val description: String, var externalErrorMessage : String? = null) : InternalError() +sealed class PaymentError(val description: String, var message : String? = null, val error: InternalError?) : InternalError() -class ForbiddenError(description: String, externalErrorMessage: String? = null) : PaymentError(description, externalErrorMessage) +class ForbiddenError(description: String, message: String? = null, error: InternalError? = null) : PaymentError(description, message, error) -class NotFoundError(description: String, externalErrorMessage: String? = null) : PaymentError(description, externalErrorMessage ) +class NotFoundError(description: String, message: String? = null, error: InternalError? = null) : PaymentError(description, message, error) -class BadGatewayError(description: String, externalErrorMessage: String? = null) : PaymentError(description, externalErrorMessage) \ No newline at end of file +class BadGatewayError(description: String, message: String? = null, error: InternalError? = null) : PaymentError(description, message, error) \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/pseudonymizer/PseudonymizerService.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/pseudonymizer/PseudonymizerService.kt deleted file mode 100644 index f89f0f327..000000000 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/pseudonymizer/PseudonymizerService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.ostelco.prime.pseudonymizer - -import org.ostelco.prime.model.ActivePseudonyms -import org.ostelco.prime.model.PseudonymEntity - -interface PseudonymizerService { - - fun getActivePseudonymsForMsisdn(msisdn: String): ActivePseudonyms - - fun getMsisdnPseudonym(msisdn: String, timestamp: Long): PseudonymEntity - - fun getSubscriberIdPseudonym(subscriberId: String, timestamp: Long): PseudonymEntity - -} \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/DelegatePubSubPublisher.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/DelegatePubSubPublisher.kt new file mode 100644 index 000000000..bf503c2f4 --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/DelegatePubSubPublisher.kt @@ -0,0 +1,53 @@ +package org.ostelco.prime.pubsub + +import com.google.api.core.ApiFuture +import com.google.api.gax.core.NoCredentialsProvider +import com.google.api.gax.grpc.GrpcTransportChannel +import com.google.api.gax.rpc.FixedTransportChannelProvider +import com.google.cloud.pubsub.v1.Publisher +import com.google.pubsub.v1.ProjectTopicName +import com.google.pubsub.v1.PubsubMessage +import io.grpc.ManagedChannelBuilder +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService + + +class DelegatePubSubPublisher( + private val topicId: String, + private val projectId: String) : PubSubPublisher { + + private lateinit var publisher: Publisher + + override lateinit var singleThreadScheduledExecutor: ScheduledExecutorService + + override fun start() { + singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor() + + val topicName = ProjectTopicName.of(projectId, topicId) + val strSocketAddress = System.getenv("PUBSUB_EMULATOR_HOST") + + publisher = if (!strSocketAddress.isNullOrEmpty()) { + val channel = ManagedChannelBuilder.forTarget(strSocketAddress) + .usePlaintext() + .build() + /* Create a publishers instance with default settings bound + to the topic. */ + val channelProvider = FixedTransportChannelProvider + .create(GrpcTransportChannel.create(channel)) + Publisher.newBuilder(topicName) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider()) + .build() + } else { + Publisher.newBuilder(topicName).build() + } + } + + override fun stop() { + publisher.shutdown() + singleThreadScheduledExecutor.shutdown() + } + + override fun publishPubSubMessage(pubsubMessage: PubsubMessage): ApiFuture = + publisher.publish(pubsubMessage) +} \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/PubSubPublisher.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/PubSubPublisher.kt new file mode 100644 index 000000000..37cb43840 --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/PubSubPublisher.kt @@ -0,0 +1,12 @@ +package org.ostelco.prime.pubsub + +import com.google.api.core.ApiFuture +import com.google.pubsub.v1.PubsubMessage +import io.dropwizard.lifecycle.Managed +import java.util.concurrent.ScheduledExecutorService + + +interface PubSubPublisher : Managed { + var singleThreadScheduledExecutor: ScheduledExecutorService + fun publishPubSubMessage(pubsubMessage: PubsubMessage): ApiFuture +} \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/PubSubSubscriber.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/PubSubSubscriber.kt new file mode 100644 index 000000000..de57d22d4 --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/pubsub/PubSubSubscriber.kt @@ -0,0 +1,87 @@ +package org.ostelco.prime.pubsub + +import arrow.core.Try +import arrow.core.getOrElse +import com.google.api.gax.core.NoCredentialsProvider +import com.google.api.gax.grpc.GrpcTransportChannel +import com.google.api.gax.rpc.FixedTransportChannelProvider +import com.google.cloud.pubsub.v1.* +import com.google.protobuf.ByteString +import com.google.pubsub.v1.* +import io.dropwizard.lifecycle.Managed +import io.grpc.ManagedChannelBuilder +import org.ostelco.prime.getLogger + +/** + * Helper for subscribing to pubsub events. + * + * Assumes that referenced subscriptions exits. + * + * As the Java library don't uses the PUBSUB_EMULATOR_HOST environment variable + * when setting up the connection to the pubsub server, an excplicit connection + * needs to be created if this variable is set. + * + * Ref.: Section "Accessing environment variables" + * https://cloud.google.com/pubsub/docs/emulator#pubsub-emulator-java + */ +abstract class PubSubSubscriber( + private val subscription: String, + private val topic: String, + private val project: String) : Managed { + + private val logger by getLogger() + + /* Is null if not used with emulator. */ + val hostport: String? = System.getenv("PUBSUB_EMULATOR_HOST") + + val subscriptionName = ProjectSubscriptionName.of(project, + subscription) + + val receiver = MessageReceiver { message, consumer -> + handler(message.data, consumer) + } + + /* For managment of subscription. */ + var subscriber: Subscriber? = null + + override fun start() { + logger.info("PUBSUB: Enabling subscription ${subscription} for project ${project} and topic ${topic}") + + subscriber = Try { + (if (!hostport.isNullOrEmpty()) { + val channel = ManagedChannelBuilder.forTarget(hostport) + .usePlaintext() + .build() + val channelProvider = FixedTransportChannelProvider + .create(GrpcTransportChannel.create(channel)) + Subscriber.newBuilder(subscriptionName, receiver) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider.create()) + .build() + } else { + Subscriber.newBuilder(subscriptionName, receiver) + .build() + }).also { it.startAsync().awaitRunning() } + }.getOrElse { + logger.error("PUBSUB: Failed to connect to service: {}", + it) + null + } + } + + override fun stop() { + Try { + subscriber?.stopAsync() + }.getOrElse { + logger.error("PUBSUB: Error disconnecting to service: {}", + it) + } + } + + /** + * Handler for message fetched from a PUBSUB "subscription". + * @param message The message itself + * @param consumer Ack handler + */ + abstract fun handler(message: ByteString, consumer: AckReplyConsumer) +} diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/sim/SimManager.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/sim/SimManager.kt new file mode 100644 index 000000000..2fc6cfcd0 --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/sim/SimManager.kt @@ -0,0 +1,11 @@ +package org.ostelco.prime.sim + +import arrow.core.Either +import org.ostelco.prime.model.SimEntry +import org.ostelco.prime.model.SimProfileStatus + +interface SimManager { + fun allocateNextEsimProfile(hlr: String, phoneType: String?) : Either + fun getSimProfile(hlr: String, iccId:String) : Either + fun getSimProfileStatusUpdates(onUpdate:(iccId:String, status: SimProfileStatus) -> Unit) +} \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/simmanager/SimManagerError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/simmanager/SimManagerError.kt new file mode 100644 index 000000000..aaab8302f --- /dev/null +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/simmanager/SimManagerError.kt @@ -0,0 +1,17 @@ +package org.ostelco.prime.simmanager + +import org.ostelco.prime.apierror.InternalError + +sealed class SimManagerError(var description: String, val error: InternalError?) : InternalError() + +class NotFoundError(description: String, error: InternalError? = null) : SimManagerError(description, error = error) + +class NotUpdatedError(description: String, error: InternalError? = null) : SimManagerError(description, error = error) + +class ForbiddenError(description: String, error: InternalError? = null) : SimManagerError(description, error = error) + +class AdapterError(description: String, error: InternalError? = null) : SimManagerError(description, error = error) + +class DatabaseError(description: String, error: InternalError? = null) : SimManagerError(description, error = error) + +class SystemError(description: String, error: InternalError? = null) : SimManagerError(description, error = error) \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt index 2bcd065d1..3c8a6c926 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt @@ -2,22 +2,94 @@ package org.ostelco.prime.storage import org.ostelco.prime.apierror.InternalError -sealed class StoreError(val type: String, val id: String, var message: String) : InternalError() +sealed class StoreError(val type: String, + val id: String, + var message: String, + val error: InternalError?) : InternalError() -class NotFoundError(type: String, id: String) : StoreError(type, id, message = "$type - $id not found.") +class NotFoundError(type: String, + id: String, + error: InternalError? = null) : + StoreError(type = type, + id = id, + message = "$type - $id not found.", + error = error) -class AlreadyExistsError(type: String, id: String) : StoreError(type, id, message = "$type - $id already exists.") +class AlreadyExistsError(type: String, + id: String, + error: InternalError? = null) : + StoreError( + type = type, + id = id, + message = "$type - $id already exists.", + error = error) -class NotCreatedError( - type: String, - id: String = "", - val expectedCount: Int = 1, - val actualCount:Int = 0) : StoreError(type, id, message = "Failed to create $type - $id") +class NotCreatedError(type: String, + id: String = "", + val expectedCount: Int = 1, + val actualCount: Int = 0, + error: InternalError? = null) : + StoreError( + type = type, + id = id, + message = "Failed to create $type - $id", + error = error) -class NotUpdatedError(type: String, id: String) : StoreError(type, id, message = "$type - $id not updated.") +class NotUpdatedError(type: String, + id: String, + error: InternalError? = null) : + StoreError(type = type, + id = id, + message = "$type - $id not updated.", + error = error) -class NotDeletedError(type: String, id: String) : StoreError(type, id, message = "$type - $id not deleted.") +class NotDeletedError(type: String, + id: String, + error: InternalError? = null) : + StoreError(type = type, + id = id, + message = "$type - $id not deleted.", + error = error) -class ValidationError( - type: String, id: String, - message: String) : StoreError(type, id, message) \ No newline at end of file +class ValidationError(type: String, + id: String, + message: String, + error: InternalError? = null) : + StoreError(type = type, + id = id, + message = message, + error = error) + +class FileDownloadError(filename: String, + status: String, + error: InternalError? = null) : + StoreError(type = "File", + id = filename, + message = "File download error : $filename, status : $status", + error = error) + +class FileDeleteError(filename: String, + status: String, + error: InternalError? = null) : + StoreError(type = "File", + id = filename, + message = "File delete error : $filename, status : $status", + error = error) + +class DatabaseError(type: String, + id: String, + message: String, + error: InternalError? = null) : + StoreError(type = type, + id = id, + message = message, + error = error) + +class SystemError(type: String, + id: String, + message: String, + error: InternalError? = null) : + StoreError(type = type, + id = id, + message = message, + error = error) diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt index c8613aa33..db6a16f1a 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt @@ -4,32 +4,39 @@ import arrow.core.Either import org.ostelco.prime.model.ApplicationToken import org.ostelco.prime.model.Bundle import org.ostelco.prime.model.ChangeSegment +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.Offer +import org.ostelco.prime.model.Plan import org.ostelco.prime.model.Product import org.ostelco.prime.model.ProductClass import org.ostelco.prime.model.PurchaseRecord +import org.ostelco.prime.model.Region +import org.ostelco.prime.model.RegionDetails +import org.ostelco.prime.model.ScanInformation import org.ostelco.prime.model.Segment -import org.ostelco.prime.model.Subscriber +import org.ostelco.prime.model.SimProfile import org.ostelco.prime.model.Subscription import org.ostelco.prime.paymentprocessor.core.PaymentError import org.ostelco.prime.paymentprocessor.core.ProductInfo +import javax.ws.rs.core.MultivaluedMap interface ClientDocumentStore { /** * Get token used for sending notification to user application */ - fun getNotificationTokens(msisdn : String): Collection + fun getNotificationTokens(customerId: String): Collection /** * Add token used for sending notification to user application */ - fun addNotificationToken(msisdn: String, token: ApplicationToken) : Boolean + fun addNotificationToken(customerId: String, token: ApplicationToken): Boolean /** * Get token used for sending notification to user application */ - fun getNotificationToken(msisdn: String, applicationID: String): ApplicationToken? + fun getNotificationToken(customerId: String, applicationID: String): ApplicationToken? /** * Get token used for sending notification to user application @@ -48,49 +55,71 @@ interface AdminDocumentStore interface ClientGraphStore { /** - * Get Subscriber Profile + * Get Customer Id */ - fun getSubscriber(subscriberId: String): Either + fun getCustomerId(identity: Identity): Either /** - * Create Subscriber Profile + * Get Customer Profile */ - fun addSubscriber(subscriber: Subscriber, referredBy: String? = null): Either + fun getCustomer(identity: Identity): Either /** - * Update Subscriber Profile + * Create Customer Profile */ - fun updateSubscriber(subscriber: Subscriber): Either + fun addCustomer(identity: Identity, customer: Customer, referredBy: String? = null): Either /** - * Remove Subscriber for testing + * Update Customer Profile */ - fun removeSubscriber(subscriberId: String): Either + fun updateCustomer(identity: Identity, nickname: String?, contactEmail: String?): Either /** - * Link Subscriber to MSISDN + * Remove Customer for testing */ - fun addSubscription(subscriberId: String, msisdn: String): Either + fun removeCustomer(identity: Identity): Either /** - * Get Products for a given subscriber + * Get Products for a given Customer */ - fun getProducts(subscriberId: String): Either> + fun getProducts(identity: Identity): Either> /** * Get Product to perform OCS Topup */ - fun getProduct(subscriberId: String, sku: String): Either + fun getProduct(identity: Identity, sku: String): Either + /** - * Get subscriptions for Client + * Get Regions (with details) associated with the Customer */ - fun getSubscriptions(subscriberId: String): Either> + fun getAllRegionDetails(identity: Identity): Either> + + /** + * Get a Region (with details) associated with the Customer + */ + fun getRegionDetails(identity: Identity, regionCode: String): Either + + /** + * Get subscriptions for Customer + */ + fun getSubscriptions(identity: Identity, regionCode: String? = null): Either> + + /** + * Get SIM Profiles for Customer + */ + fun getSimProfiles(identity: Identity, regionCode: String? = null): Either> + + /** + * Provision new SIM Profile for Customer + */ + + fun provisionSimProfile(identity: Identity, regionCode: String, profileType: String?): Either /** * Get balance for Client */ - fun getBundles(subscriberId: String): Either> + fun getBundles(identity: Identity): Either> /** * Set balance after OCS Topup or Consumption @@ -98,45 +127,85 @@ interface ClientGraphStore { fun updateBundle(bundle: Bundle): Either /** - * Get msisdn for the given subscription-id + * Set balance after OCS Topup or Consumption */ - fun getMsisdn(subscriptionId: String): Either + suspend fun consume(msisdn: String, usedBytes: Long, requestedBytes: Long, callback: (Either) -> Unit) /** * Get all PurchaseRecords */ - fun getPurchaseRecords(subscriberId: String): Either> + fun getPurchaseRecords(identity: Identity): Either> /** * Add PurchaseRecord after Purchase operation */ - fun addPurchaseRecord(subscriberId: String, purchase: PurchaseRecord): Either + fun addPurchaseRecord(customerId: String, purchase: PurchaseRecord): Either /** * Get list of users this user has referred to */ - fun getReferrals(subscriberId: String): Either> + fun getReferrals(identity: Identity): Either> /** * Get user who has referred this user. */ - fun getReferredBy(subscriberId: String): Either + fun getReferredBy(identity: Identity): Either /** * Temporary method to perform purchase as atomic transaction */ - fun purchaseProduct(subscriberId: String, sku: String, sourceId: String?, saveCard: Boolean): Either + fun purchaseProduct(identity: Identity, sku: String, sourceId: String?, saveCard: Boolean): Either + + /** + * Generate new eKYC scanId for the customer. + */ + fun createNewJumioKycScanId(identity: Identity, regionCode: String): Either + + /** + * Get the country code for the scan. + */ + fun getCountryCodeForScan(scanId: String): Either + + /** + * Get information about an eKYC scan for the customer. + */ + fun getScanInformation(identity: Identity, scanId: String): Either + + /** + * Get Customer Data from Singapore MyInfo Data using authorisationCode, and store and return it + */ + fun getCustomerMyInfoData(identity: Identity, authorisationCode: String): Either + + /** + * Validate and store NRIC/FIN ID + */ + fun checkNricFinIdUsingDave(identity: Identity, nricFinId: String): Either + + /** + * Save address and Phone number + */ + fun saveAddressAndPhoneNumber(identity: Identity, address: String, phoneNumber: String) : Either } +data class ConsumptionResult(val msisdnAnalyticsId: String, val granted: Long, val balance: Long) + interface AdminGraphStore { fun getMsisdnToBundleMap(): Map fun getAllBundles(): Collection - fun getSubscriberToBundleIdMap(): Map - fun getSubscriberToMsisdnMap(): Map + fun getCustomerToBundleIdMap(): Map + fun getCustomerToMsisdnMap(): Map + fun getCustomerForMsisdn(msisdn: String): Either + + /** + * Link Customer to MSISDN + */ + @Deprecated(message = "Assigning MSISDN to Customer via Admin API will be removed in future.") + fun addSubscription(identity: Identity, msisdn: String): Either // simple create fun createProductClass(productClass: ProductClass): Either + fun createProduct(product: Product): Either fun createSegment(segment: Segment): Either fun createOffer(offer: Offer): Either @@ -145,15 +214,74 @@ interface AdminGraphStore { // updating an Offer and Product is not allowed fun updateSegment(segment: Segment): Either - fun getSubscriberCount(): Long - fun getReferredSubscriberCount(): Long - fun getPaidSubscriberCount(): Long + fun getCustomerCount(): Long + fun getReferredCustomerCount(): Long + fun getPaidCustomerCount(): Long + + /* For managing plans and subscription to plans. */ + + /** + * Get details for a specific plan. + * @param planId - The name/id of the plan + * @return Plan details if found + */ + fun getPlan(planId: String): Either + + /** + * Get all plans that a customer subscribes to. + * @param identity - The identity of the customer + * @return List with plan details if found + */ + fun getPlans(identity: Identity): Either> + + /** + * Create a new plan. + * @param plan - Plan details + * @return Unit value if created successfully + */ + fun createPlan(plan: Plan): Either + + /** + * Remove a plan. + * @param planId - The name/id of the plan + * @return Unit value if removed successfully + */ + fun deletePlan(planId: String): Either + + /** + * Set up a customer with a subscription to a specific plan. + * @param identity - The identity of the customer + * @param planId - The name/id of the plan + * @param trialEnd - Epoch timestamp for when the trial period ends + * @return Unit value if the subscription was created successfully + */ + fun subscribeToPlan(identity: Identity, planId: String, trialEnd: Long = 0): Either + + /** + * Remove the subscription to a plan for a specific subscrber. + * @param identity - The identity of the customer + * @param planId - The name/id of the plan + * @param atIntervalEnd - Remove at end of curren subscription period + * @return Unit value if the subscription was removed successfully + */ + fun unsubscribeFromPlan(identity: Identity, planId: String, atIntervalEnd: Boolean = false): Either + + /** + * Adds a purchase record to customer on start of or renewal + * of a subscription. + * @param invoiceId - The reference to the invoice that has been paid + * @param customerId - The customer that got charged + * @param sku - The product/plan bought + * @param amount - Cost of the product/plan + * @param currency - Currency used + */ + fun subscriptionPurchaseReport(invoiceId: String, customerId: String, sku: String, amount: Long, currency: String): Either // atomic import of Offer + Product + Segment fun atomicCreateOffer( offer: Offer, segments: Collection = emptyList(), - products: Collection = emptyList()) : Either + products: Collection = emptyList()): Either fun atomicCreateSegments(createSegments: Collection): Either @@ -162,6 +290,17 @@ interface AdminGraphStore { fun atomicRemoveFromSegments(removeFromSegments: Collection): Either fun atomicChangeSegments(changeSegments: Collection): Either + // Method to perform a full refund of a purchase + fun refundPurchase(identity: Identity, purchaseRecordId: String, reason: String): Either + + // update the scan information with scan result + fun updateScanInformation(scanInformation: ScanInformation, vendorData: MultivaluedMap): Either + + // Retrieve all scan information for the customer + fun getAllScanInformation(identity: Identity): Either> + + fun createRegion(region: Region): Either + // simple getAll // fun getOffers(): Collection // fun getSegments(): Collection @@ -174,3 +313,9 @@ interface AdminGraphStore { // fun getSegment(id: String): Segment? // fun getProductClass(id: String): ProductClass? } + +interface ScanInformationStore { + // Function to upsert scan information data from the 3rd party eKYC scan + fun upsertVendorScanInformation(customerId: String, countryCode: String, vendorData: MultivaluedMap): Either + fun getExtendedStatusInformation(vendorData: MultivaluedMap): Map +} \ No newline at end of file diff --git a/prime-modules/src/test/kotlin/org/ostelco/prime/jsonmapper/JsonMapperTest.kt b/prime-modules/src/test/kotlin/org/ostelco/prime/jsonmapper/JsonMapperTest.kt new file mode 100644 index 000000000..524de81a8 --- /dev/null +++ b/prime-modules/src/test/kotlin/org/ostelco/prime/jsonmapper/JsonMapperTest.kt @@ -0,0 +1,18 @@ +package org.ostelco.prime.jsonmapper + +import org.junit.Test + +class JsonMapperTest { + + @Test + fun `test kotlin jackson module`() { + objectMapper.readValue( + asJson(TestDataClass(aProperty = "foo", abProperty = "bar", Name = "Vihang")), + TestDataClass::class.java) + } +} + +data class TestDataClass( + @JvmField val aProperty: String, + val abProperty: String, + @JvmField val Name: String) diff --git a/prime/Dockerfile b/prime/Dockerfile index c89b95728..ca7e5e173 100644 --- a/prime/Dockerfile +++ b/prime/Dockerfile @@ -1,9 +1,10 @@ -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" COPY script/start.sh /start.sh +COPY config/customer.graphqls /config/ COPY config/config.yaml /config/ COPY build/libs/prime-uber.jar /prime.jar diff --git a/prime/Dockerfile.test b/prime/Dockerfile.test index 79696f4f5..99dd54396 100644 --- a/prime/Dockerfile.test +++ b/prime/Dockerfile.test @@ -1,8 +1,8 @@ # This Dockerfile is used when running locally using docker-compose for Acceptance Testing. -FROM openjdk:11 +FROM azul/zulu-openjdk:11.0.1-11.2 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -15,9 +15,11 @@ COPY script/start.sh /start.sh COPY script/wait.sh /wait.sh # test.yaml is copied as config.yaml for AT. -COPY config/pantel-prod.json /secret/pantel-prod.json +COPY config/prime-service-account.json /secret/prime-service-account.json COPY config/testDb.csv /config-data/imeiDb.csv +COPY config/customer.graphqls /config/customer.graphqls COPY config/test.yaml /config/config.yaml +COPY config/test_keyset_pub_cltxt /config/test_keyset_pub_cltxt_global COPY build/libs/prime-uber.jar /prime.jar diff --git a/prime/build.gradle b/prime/build.gradle index 56b9b496d..6a32fa0cb 100644 --- a/prime/build.gradle +++ b/prime/build.gradle @@ -1,7 +1,7 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "application" - id "com.github.johnrengelman.shadow" version "4.0.1" + id "com.github.johnrengelman.shadow" version "5.0.0" id "idea" } @@ -18,7 +18,7 @@ sourceSets { } } -version = "1.17.0" +version = "1.36.0" repositories { maven { @@ -31,34 +31,34 @@ dependencies { implementation project(':prime-modules') // prime-modules - runtimeOnly project(':ocs') + runtimeOnly project(':ocs-ktc') runtimeOnly project(':firebase-store') runtimeOnly project(':neo4j-store') - runtimeOnly project(':pseudonym-server') - runtimeOnly project(':client-api') + runtimeOnly project(':customer-endpoint') + runtimeOnly project(':ekyc') + runtimeOnly project(':graphql') runtimeOnly project(':admin-api') runtimeOnly project(':app-notifier') + runtimeOnly project(':email-notifier') runtimeOnly project(':payment-processor') runtimeOnly project(':analytics-module') runtimeOnly project(':slack') runtimeOnly project(':imei-lookup') runtimeOnly project(':jersey') + runtimeOnly project(':scaninfo-datastore') + runtimeOnly project(':sim-administration:simmanager') implementation "io.dropwizard:dropwizard-http2:$dropwizardVersion" runtimeOnly "io.dropwizard:dropwizard-json-logging:$dropwizardVersion" + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" implementation "com.google.guava:guava:$guavaVersion" implementation 'org.dhatim:dropwizard-prometheus:2.2.0' testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" testImplementation "org.mockito:mockito-core:$mockitoVersion" testImplementation 'com.lmax:disruptor:3.4.2' - testImplementation 'com.palantir.docker.compose:docker-compose-rule-junit4:0.34.0' + testImplementation "com.palantir.docker.compose:docker-compose-rule-junit4:$dockerComposeJunitRuleVersion" testImplementation 'org.dhatim:dropwizard-prometheus:2.2.0' - - integrationImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" - integrationImplementation "org.mockito:mockito-core:$mockitoVersion" - integrationImplementation 'com.lmax:disruptor:3.4.2' - integrationImplementation 'com.palantir.docker.compose:docker-compose-rule-junit4:0.34.0' } configurations { @@ -66,10 +66,11 @@ configurations { integrationImplementation.extendsFrom implementation integrationImplementation.extendsFrom runtime integrationImplementation.extendsFrom runtimeOnly + integrationImplementation.extendsFrom testImplementation } task integration(type: Test, description: 'Runs the integration tests.', group: 'Verification') { - environment("GOOGLE_APPLICATION_CREDENTIALS", "config/pantel-prod.json") + environment("GOOGLE_APPLICATION_CREDENTIALS", "config/prime-service-account.json") testClassesDirs = sourceSets.integration.output.classesDirs classpath = sourceSets.integration.runtimeClasspath } @@ -95,4 +96,4 @@ idea { module { testSourceDirs += file('src/integration-tests/kotlin') } -} \ No newline at end of file +} diff --git a/prime/cloudbuild.dev.yaml b/prime/cloudbuild.dev.yaml index 3394a66cf..033ab8f7b 100644 --- a/prime/cloudbuild.dev.yaml +++ b/prime/cloudbuild.dev.yaml @@ -50,7 +50,7 @@ steps: path: /root/out_zip # Build docker images - name: gcr.io/cloud-builders/docker - args: ['build', '--tag=eu.gcr.io/$PROJECT_ID/prime:$SHORT_SHA', '--cache-from', 'openjdk:11', 'prime'] + args: ['build', '--tag=eu.gcr.io/$PROJECT_ID/prime:$SHORT_SHA', '--cache-from', 'azul/zulu-openjdk:11.0.1-11.2', 'prime'] timeout: 120s # Deploy new docker image to Google Kubernetes Engine (GKE) - name: ubuntu diff --git a/prime/cloudbuild.yaml b/prime/cloudbuild.yaml index ff9d63966..b4be1fde0 100644 --- a/prime/cloudbuild.yaml +++ b/prime/cloudbuild.yaml @@ -50,7 +50,7 @@ steps: path: /root/out_zip # Build docker images - name: gcr.io/cloud-builders/docker - args: ['build', '--tag=eu.gcr.io/$PROJECT_ID/prime:$TAG_NAME', '--cache-from', 'openjdk:11', 'prime'] + args: ['build', '--tag=eu.gcr.io/$PROJECT_ID/prime:$TAG_NAME', '--cache-from', 'azul/zulu-openjdk:11.0.1-11.2', 'prime'] timeout: 120s # Deploy new docker image to Google Kubernetes Engine (GKE) - name: ubuntu diff --git a/prime/config/.gitignore b/prime/config/.gitignore index bf045303f..3b858adea 100644 --- a/prime/config/.gitignore +++ b/prime/config/.gitignore @@ -1 +1 @@ -pantel-prod.json \ No newline at end of file +prime-service-account.json \ No newline at end of file diff --git a/prime/config/config.yaml b/prime/config/config.yaml index 73f670128..2e2b6fef1 100644 --- a/prime/config/config.yaml +++ b/prime/config/config.yaml @@ -1,61 +1,128 @@ modules: -- type: jersey -- type: slack - config: - notifications: - channel: ${SLACK_CHANNEL} - webHookUri: ${SLACK_WEBHOOK_URI} + - type: jersey + config: + authenticationCachePolicy: maximumSize=10000, expireAfterAccess=10m + jerseyClient: + timeout: 2s + - type: slack + config: + notifications: + channel: ${SLACK_CHANNEL} + webHookUri: ${SLACK_WEBHOOK_URI} + httpClient: + timeout: 3s + connectionRequestTimeout: 1s + - type: Imei-lookup + config: + csvFile: /config-data/imeiDb.csv + - type: firebase + config: + configFile: /secret/prime-service-account.json + rootPath: ${FIREBASE_ROOT_PATH} + - type: email + config: + mandrillApiKey: ${MANDRILL_API_KEY} httpClient: timeout: 3s connectionRequestTimeout: 1s -- type: Imei-lookup - config: - csvFile: /config-data/imeiDb.csv -- type: firebase - config: - configFile: /secret/pantel-prod.json - rootPath: ${FIREBASE_ROOT_PATH} -- type: neo4j - config: - host: ${NEO4J_HOST} - protocol: bolt+routing -- type: analytics - config: - projectId: pantel-2decb - dataTrafficTopicId: ${DATA_TRAFFIC_TOPIC} - purchaseInfoTopicId: ${PURCHASE_INFO_TOPIC} - activeUsersTopicId: ${ACTIVE_USERS_TOPIC} -- type: ocs - config: - lowBalanceThreshold: 100000000 -- type: pseudonymizer - config: - namespace: ${DATASTORE_NAMESPACE:-""} -- type: api - config: - authenticationCachePolicy: maximumSize=10000, expireAfterAccess=10m - jerseyClient: - timeout: 2s -- type: stripe-payment-processor -- type: firebase-app-notifier - config: - configFile: /secret/pantel-prod.json -- type: admin + - type: kyc + config: + myInfoApiUri: ${MY_INFO_API_URI} + myInfoApiClientId: ${MY_INFO_API_CLIENT_ID} + myInfoApiClientSecret: ${MY_INFO_API_CLIENT_SECRET} + myInfoApiRealm: ${MY_INFO_API_REALM} + myInfoRedirectUri: ${MY_INFO_REDIRECT_URI} + myInfoServerPublicKey: ${MY_INFO_SERVER_PUBLIC_KEY} + myInfoClientPrivateKey: ${MY_INFO_CLIENT_PRIVATE_KEY} + - type: neo4j + config: + host: ${NEO4J_HOST} + protocol: bolt+routing + - type: analytics + config: + projectId: ${GCP_PROJECT_ID} + dataTrafficTopicId: ${DATA_TRAFFIC_TOPIC} + purchaseInfoTopicId: ${PURCHASE_INFO_TOPIC} + activeUsersTopicId: ${ACTIVE_USERS_TOPIC} + - type: ocs + config: + lowBalanceThreshold: 100000000 + pubSubChannel: + projectId: ${GCP_PROJECT_ID} + activateTopicId: ${ACTIVATE_TOPIC_ID} + ccrSubscriptionId: ${CCR_SUBSCRIPTION_ID} + - type: api + - type: stripe-payment-processor + config: + projectId: ${GCP_PROJECT_ID} + stripeEventTopicId: ${STRIPE_EVENT_TOPIC} + stripeEventStoreSubscriptionId: ${STRIPE_EVENT_STORE_SUBSCRIPTION} + stripeEventReportSubscriptionId: ${STRIPE_EVENT_REPORT_SUBSCRIPTION} + - type: firebase-app-notifier + config: + configFile: /secret/prime-service-account.json + - type: admin + - type: graphql + - type: scaninfo-store + config: + keysetFilePathPrefix: /scaninfo-keysets/encrypt_key + namespace: ${DATASTORE_NAMESPACE:-""} + - type: sim-manager + config: + openApi: + name: SIM admin + description: SIM administration service + termsOfService: http://example.org + contactEmail: rmz@telenordigital.com + resourcePackage: org.ostelco + hlrs: + - name: Loltel + endpoint: ${WG2_ENDPOINT} + userId: ${WG2_USER} + apiKey: ${WG2_API_KEY} + profileVendors: + - name: Idemia + es2plusEndpoint: ${ES2PLUS_ENDPOINT} + es9plusEndpoint: ${ES9PLUS_ENDPOINT} + requesterIdentifier: ${FUNCTION_REQUESTER_IDENTIFIER} + # Note, list must end with a wildcard match + phoneTypes: + - regex: "android.*" + profile: Loltel_ANDROID_1 + - regex: "iphone.*" + profile: LOLTEL_IPHONE_1 + - regex: ".*" + profile: LOLTEL_IPHONE_1 + database: + driverClass: org.postgresql.Driver + user: ${DB_USER} + password: ${DB_PASSWORD} + url: ${DB_URL} + httpClient: + timeout: 10000ms + tls: + # Default is 500 milliseconds, we need more when debugging. + # protocol: TLSv1.2 + keyStoreType: JKS + keyStorePath: /certs/idemia-client-cert.jks + keyStorePassword: foobar + verifyHostname: false + trustSelfSignedCertificates: true server: applicationConnectors: - - type: h2c - port: 8080 - maxConcurrentStreams: 1024 - initialStreamRecvWindow: 65535 + - type: h2c + port: 8080 + maxConcurrentStreams: 1024 + initialStreamRecvWindow: 65535 requestLog: appenders: - - type: console - layout: - type: access-json - filterFactories: - - type: URI - uri: prometheus-metrics + - type: console + layout: + type: access-json + filterFactories: + - type: URI + uri: prometheus-metrics logging: level: INFO @@ -63,9 +130,9 @@ logging: org.ostelco: DEBUG org.dhatim.dropwizard.prometheus.DropwizardMetricsExporter: ERROR appenders: - - type: slack - - type: console - layout: - type: json - customFieldNames: - level: severity \ No newline at end of file + - type: slack + - type: console + layout: + type: json + customFieldNames: + level: severity diff --git a/prime/config/customer.graphqls b/prime/config/customer.graphqls new file mode 100644 index 000000000..2f3556820 --- /dev/null +++ b/prime/config/customer.graphqls @@ -0,0 +1,49 @@ +schema { + query: QueryType +} + +type QueryType { + context(id: String): Context +} + +type Context { + customer: Customer + bundles: [Bundle] + subscriptions: [Subscription] + products: [Product] + purchases: [Purchase] +} + +type Customer { + id: String + contactEmail: String + nickname: String + referralId: String + analyticsId: String +} + +type Bundle { + id: String + balance: Long +} + +type Subscription { + msisdn: String + analyticsId: String +} + +type Product { + sku: String + price: Price +} + +type Price { + amount: Int + currency: String +} + +type Purchase { + id: String + product: Product + timestamp: Long +} \ No newline at end of file diff --git a/prime/config/test.yaml b/prime/config/test.yaml index e53680cbd..8c061b8c3 100644 --- a/prime/config/test.yaml +++ b/prime/config/test.yaml @@ -1,52 +1,71 @@ # This config is used as config.yaml when prime is running using docker-compose for Acceptance Testing modules: -- type: jersey -- type: firebase - config: - configFile: /secret/pantel-prod.json - rootPath: test -- type: Imei-lookup - config: - csvFile: /config-data/imeiDb.csv -- type: neo4j - config: - host: neo4j - protocol: bolt -- type: analytics - config: - projectId: pantel-2decb - dataTrafficTopicId: data-traffic - purchaseInfoTopicId: purchase-info - activeUsersTopicId: active-users -- type: ocs - config: - lowBalanceThreshold: 0 -- type: pseudonymizer - config: - datastoreType: emulator -- type: api - config: - authenticationCachePolicy: maximumSize=10000, expireAfterAccess=10m - jerseyClient: - timeout: 3s - connectionRequestTimeout: 1s -- type: stripe-payment-processor -- type: firebase-app-notifier - config: - configFile: /secret/pantel-prod.json -- type: admin - + - type: jersey + config: + authenticationCachePolicy: maximumSize=10000, expireAfterAccess=10m + jerseyClient: + timeout: 3s + connectionRequestTimeout: 1s + - type: firebase + config: + configFile: /secret/prime-service-account.json + rootPath: test + - type: Imei-lookup + config: + csvFile: /config-data/imeiDb.csv + - type: kyc + config: + myInfoApiUri: http://ext-myinfo-emulator:8080 + myInfoApiClientId: STG2-MYINFO-SELF-TEST + myInfoApiClientSecret: 44d953c796cccebcec9bdc826852857ab412fbe2 + myInfoApiRealm: http://localhost:3001 + myInfoRedirectUri: http://localhost:3001/callback + myInfoServerPublicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqWWA2rH1wuBkd1zp0uOh+dnCRRcQWiI89ildk9UGSd3kgzPx1mYEL40cBOBVpSIkbRp65fJDjBm+MhzlHBgWZ1q27S30nczwnzAUJqUfJvLeCW7HLwqwPVSQlqby/n4MV2AKUu0jMacOeXE3Bevm92BEOH9wQhv81Rd7HZXRJGgMecqmVehMT7Mk88xHJvvWD1bYSQL5ADnNz1v0wq/afOVYPWAOl7xYoIgokYJQD3WwnKHVcotZcP8B5mu0AuMnP71JnzjVsRpwuO8N/m28fmzXCY7ARwRpz20Q6oOq09+ZMiJkpdT5TTqEF1u3FxTq5TY8CY60q9L5RqEUNJA9fQIDAQAB + myInfoClientPrivateKey: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDGBRdsiDqKPGyHgOpzxmSU2EQkm+zYZLvlPlwkwyfFWLndFLZ3saxJS+LIixsFhunrrUT9ZZ0x+bB6MV55o70z4ABOJRFNWx1wbMGqdiC0Fyfpwad3iYpRVjZO+5etHA9JEoaTPoFxv+ktd8kVAL9P5I7/Pi6g1R+B2t2lsaE2bMSwtZqgs55gb7fsCR3Z4nQi7BddYR7MZ2lAMWf7h7Dkm6uRlGhl2RvtmYa6dXFnK3RhIpdQOUT3quyhweMGspowC/tYSG+BNhy1WukbwhIP5vTAvv1WbHTg+WaUUV+pP0TjPQcY73clHxNpI5zrNqDmwD2rogNfePKRUI63yBUfAgMBAAECggEAGy/7xVT25J/jLr+OcRLeIGmJAZW+8P7zpUfoksuQnFHQQwBjBRAJ3Y5jtrESprGdUFRb0oavDHuBtWUt2XmXspWgtRn1xC8sXZExDdxmJRPA0SFbgtgJe51gm3uDmarullPK0lCUqS92Ll3x58ZQfgGdeIHrGP3p84Q/Rk6bGcObcPhDYWSOYKm4i2DPM01bnZG2z4BcrWSseOmeWUxqZcMlGz9GAyepUU/EoqRIHxw/2Y+TGus1JSy5DdhPE0HAEWKZH729ZdoyikOZCMxApQglUkRwkwhtXzVAemm6OSoy3BEWvSEJh/F82tFrmquUoe/xd5JastlBHyD78RAakQKBgQDkHAzo1fowRI19tk7VCPn0zMdF/UTRghtLywc/4xnw1Nd13m+orArOdVzPlQokLVNL81dIVKXnId0Hw/kX8CRyRYz8tkL81spc39DfalZW7QI7Fschfq1Htgkxd/QEjBlIaqjkOjGSbX9xYjYU1Db8PuGoGXWOsYiv9PCsKR056wKBgQDeOzfZSpV5kX8SECJXRA+emyCnO9S29p0W+5BCTQp3OPnmbL7b/mGqBVJ0DC+IiN67Lu8xxzejswqLZqaRvmQuioqH+8mOGpXYZwhShAif2AuixxvL7OK6dvDmMqoKhBI9nZ9+XI60Cd/LjnWgyFO04uq4otnTukmYsSP+fp6wnQKBgEopYH0WjFfDAelcKzcRywouxZ7Yn9Ypoaw7nujDcfydhktY/R5uiLjk6T7H6tsmLU2lGLx4YNPLa6wJp+ODfKX2PMcwjojbYEFftu3cCaQLPE1vs2ANalLFOSnvINOVpOapXq2Mye8cUHHRh1mwQQwzeXQIivLQf2sNjG28lDbvAoGACsh80UJZNmjk7Y9y2yEmUN/eGb9Bdw9IWBEk0tLCKz7MgW3NZQdW3dUcRx1AQTPC+vowCQ5NmNfbLyBv/KpsWgXG6wpAoXCQzMtTEA3wDTGCfweCRcbcyYdz8PeMYK4/5FV9o7gCBKJmBY6IDqEpzqEkGolsYGWtpIcT5Alo0dECgYEA3hzC9NLwumi/1JWm+ASSADTO3rrGo9hicG/WKGzSHD5l1f+IO1SfmUN/6i2JjcnE07eYArNrCfbMgkFavj502ne2fSaYM4p0o147O9Ty8jCyY9vuh/ZGid6qUe3TBI6/okWfmYw6FVbRpNfVEeG7kPfkDW/JdH7qkWTFbh3eH1k= + - type: neo4j + config: + host: neo4j + protocol: bolt + - type: analytics + config: + projectId: ${GCP_PROJECT_ID} + dataTrafficTopicId: data-traffic + purchaseInfoTopicId: purchase-info + activeUsersTopicId: active-users + - type: ocs + config: + lowBalanceThreshold: 0 + pubSubChannel: + projectId: ${GCP_PROJECT_ID} + activateTopicId: ocs-activate + ccrSubscriptionId: ocs-ccr-sub + - type: api + - type: stripe-payment-processor + config: + projectId: ${GCP_PROJECT_ID} + stripeEventTopicId: stripe-event + stripeEventStoreSubscriptionId: stripe-event-store-sub + stripeEventReportSubscriptionId: stripe-event-report-sub + - type: firebase-app-notifier + config: + configFile: /secret/prime-service-account.json + - type: admin + - type: graphql + - type: scaninfo-store + config: + storeType: emulator + keysetFilePathPrefix: /config/test_keyset_pub_cltxt server: applicationConnectors: - - type: h2c - port: 8080 - maxConcurrentStreams: 1024 - initialStreamRecvWindow: 65535 + - type: h2c + port: 8080 + maxConcurrentStreams: 1024 + initialStreamRecvWindow: 65535 logging: level: INFO loggers: org.ostelco: DEBUG # suppress exception logged while connecting to real bigQuery 3 times before connecting to emulator - com.google.auth.oauth2.ComputeEngineCredentials: ERROR \ No newline at end of file + com.google.auth.oauth2.ComputeEngineCredentials: ERROR diff --git a/prime/config/test_keyset_pub_cltxt b/prime/config/test_keyset_pub_cltxt new file mode 100644 index 000000000..eb03bf838 --- /dev/null +++ b/prime/config/test_keyset_pub_cltxt @@ -0,0 +1,13 @@ +{ + "primaryKeyId": 1759458161, + "key": [{ + "keyData": { + "typeUrl": "type.googleapis.com/google.crypto.tink.EciesAeadHkdfPublicKey", + "keyMaterialType": "ASYMMETRIC_PUBLIC", + "value": "EkQKBAgCEAMSOhI4CjB0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5jcnlwdG8udGluay5BZXNHY21LZXkSAhAQGAEYARohAJaQ5xGnYxxncUyJlOvSTCmgM7JLIrgptP9tWcLi334BIiA/UwjIe8pjK5dwa0ghB0m1jYN/sdyVQUkfwPrHDYBIhw==" + }, + "outputPrefixType": "TINK", + "keyId": 1759458161, + "status": "ENABLED" + }] +} \ No newline at end of file diff --git a/prime/config/test_keyset_pvt_cltxt b/prime/config/test_keyset_pvt_cltxt new file mode 100644 index 000000000..aa773b54b --- /dev/null +++ b/prime/config/test_keyset_pvt_cltxt @@ -0,0 +1,13 @@ +{ + "primaryKeyId": 1759458161, + "key": [{ + "keyData": { + "typeUrl": "type.googleapis.com/google.crypto.tink.EciesAeadHkdfPrivateKey", + "keyMaterialType": "ASYMMETRIC_PRIVATE", + "value": "EosBEkQKBAgCEAMSOhI4CjB0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5jcnlwdG8udGluay5BZXNHY21LZXkSAhAQGAEYARohAJaQ5xGnYxxncUyJlOvSTCmgM7JLIrgptP9tWcLi334BIiA/UwjIe8pjK5dwa0ghB0m1jYN/sdyVQUkfwPrHDYBIhxogWqVOo7UmnrgRwV11ySP2ZEWhP8+zpJGXAZSaqFL8U/M=" + }, + "outputPrefixType": "TINK", + "keyId": 1759458161, + "status": "ENABLED" + }] +} \ No newline at end of file diff --git a/prime/infra/LEGACY.md b/prime/infra/LEGACY.md new file mode 100644 index 000000000..7840c236e --- /dev/null +++ b/prime/infra/LEGACY.md @@ -0,0 +1,306 @@ +# Deploying Prime to Kubernetes + +### TL;DR + +* **Option 1:** Run this from project root folder (ostelco-core) on `master` branch + + + prime/script/deploy.sh + +* **Option 2:** Push a tag `prime-X.Y.Z` on `master` branch. + + +=== + + +### Setup + +Set variables by doing this in `prime` directory: + + #GCP_PROJECT_ID=$(gcloud config get-value project -q) + +```bash +export GCP_PROJECT_ID="$(gcloud config get-value project -q)" +echo "GCP_PROJECT_ID=GCP_PROJECT_ID" +export PRIME_VERSION="$(gradle properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" +echo "PRIME_VERSION=$PRIME_VERSION" +``` + +Reference: + * https://cloud.google.com/endpoints/docs/grpc/get-started-grpc-kubernetes-engine + +## Deploying to GKE using GCP Container/Cloud Builder + +### Using CLI +In the project (ostelco-core) root folder: + +```bash +gcloud container builds submit \ + --config prime/prod/cloudbuild.yaml \ + --substitutions TAG_NAME=${PRIME_VERSION},BRANCH_NAME=$(git branch | grep \* | cut -d ' ' -f2) . +``` + +#### Limitations + * Remove .git from `.gcloudignore` and detect branch name and check for uncommitted changes. + +### Using build trigger + + * A build trigger is configured in GCP Container/Cloud Builder to build and deploy prime to GKE cluster + just by adding a git tag on `master` branch. + * The tag name should be `prime-*` + +#### Limitations + * When using build trigger, the version tag on docker images is `prime-X.Y.Z` instead of `X.Y.Z`. + +### Future Improvements + * Create a custom build docker image. (suggestion by Vihang). + * Run AT as quality gate. (suggestion by Remseth). + * Use it for CI. Currently it is only CD. (suggestion by Remseth). + * Use `git-sha` along/instead with version (suggestion by Håvard). + +### References + * Config: https://cloud.google.com/container-builder/docs/build-config + * Running locally: https://cloud.google.com/container-builder/docs/build-debug-locally + * Cloud builders: https://cloud.google.com/container-builder/docs/cloud-builders + * Customization: https://cloud.google.com/container-builder/docs/create-custom-build-steps + * Optimization: https://cloud.google.com/container-builder/docs/speeding-up-builds + * Custom Github web-hooks: https://cloud.google.com/container-builder/docs/configure-third-party-notifications + * Storing secrets for AT: https://cloud.google.com/container-builder/docs/securing-builds/use-encrypted-secrets-credentials + +## Secrets + +```bash +kubectl create secret generic prime-service-account.json --from-file prime/config/prime-service-account.json +``` + +Reference: + * https://cloud.google.com/kubernetes-engine/docs/concepts/secret + * https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-files-from-a-pod + * https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-environment-variables + +## Endpoint + +Generate self-contained protobuf descriptor file - `ocs_descriptor.pb` & `metrics_descriptor.pb` + +```bash +pyenv versions +pyenv local 3.5.2 +pip install grpcio grpcio-tools + +python -m grpc_tools.protoc \ + --include_imports \ + --include_source_info \ + --proto_path=ocs-grpc-api/src/main/proto \ + --descriptor_set_out=ocs_descriptor.pb \ + ocs.proto + +python -m grpc_tools.protoc \ + --include_imports \ + --include_source_info \ + --proto_path=analytics-grpc-api/src/main/proto \ + --descriptor_set_out=metrics_descriptor.pb \ + prime_metrics.proto +``` + +Deploy endpoints + +```bash +gcloud endpoints services deploy ocs_descriptor.pb prime/infra/prod/ocs-api.yaml +gcloud endpoints services deploy metrics_descriptor.pb prime/infra/prod/metrics-api.yaml +``` + +## Deployment & Service + +Increment the docker image tag (version) for next two steps. + +Build the Docker image (In the folder with Dockerfile) + +```bash +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/prime:${PRIME_VERSION} prime +``` + +Push to the registry + +```bash +docker push eu.gcr.io/${GCP_PROJECT_ID}/prime:${PRIME_VERSION} +``` + +Update the tag (version) of prime's docker image in `infra/prod/prime.yaml`. + +Apply the deployment & service + +```bash +sed -e 's/PRIME_VERSION/${PRIME_VERSION}/g; s/_GCP_PROJECT_ID/'"${GCP_PROJECT_ID}"'/g' prime/infra/prod/prime.yaml | kubectl apply -f - +``` + +Details of the deployment + +```bash +kubectl describe deployment prime +kubectl get pods +``` + +Details of service + +```bash +kubectl describe service prime-service +``` + +## API Endpoint + +```bash +gcloud endpoints services deploy prime/infra/prod/prime-customer-api.yaml +``` + +## SSL secrets for api.ostelco.org, ocs.ostelco.org & metrics.ostelco.org +The endpoints runtime expects the SSL configuration to be named +as `nginx.crt` and `nginx.key`. Sample command to create the secret: +```bash +kubectl create secret generic api-ostelco-ssl \ + --from-file=./nginx.crt \ + --from-file=./nginx.key +``` +The secret for ... + * `api.ostelco.org` is in `api-ostelco-ssl` + * `ocs.ostelco.org` is in `ocs-ostelco-ssl` + * `metrics.ostelco.org` is in `metrics-ostelco-ssl` + +# For Dev cluster + +## One time setup + +### Cluster + * Create cluster + +```bash +gcloud container clusters create dev-cluster \ + --scopes=default,bigquery,datastore,pubsub,sql,storage-rw \ + --zone=europe-west1-b \ + --num-nodes=1 +``` + * Create node-pool +```bash +gcloud container node-pools create dev-node-pool \ + --cluster=dev-cluster \ + --machine-type=n1-standard-2 \ + --scopes=default,bigquery,datastore,pubsub,sql,storage-rw \ + --num-nodes=3 \ + --zone=europe-west1-b \ + --enable-autorepair +``` + * Delete default pool +```bash +gcloud container node-pools delete default-pool \ + --cluster=dev-cluster \ + --zone=europe-west1-b +``` + +### Endpoints + + * OCS gRPC endpoint + +Generate self-contained protobuf descriptor file - ocs_descriptor.pb + +```bash +pip install grpcio grpcio-tools + +python -m grpc_tools.protoc \ + --include_imports \ + --include_source_info \ + --proto_path=ocs-grpc-api/src/main/proto \ + --descriptor_set_out=ocs_descriptor.pb \ + ocs.proto + +python -m grpc_tools.protoc \ + --include_imports \ + --include_source_info \ + --proto_path=analytics-grpc-api/src/main/proto \ + --descriptor_set_out=metrics_descriptor.pb \ + prime_metrics.proto +``` + +Deploy endpoints + +```bash +gcloud endpoints services deploy ocs_descriptor.pb prime/infra/dev/ocs-api.yaml +gcloud endpoints services deploy metrics_descriptor.pb prime/infra/dev/metrics-api.yaml +``` + + * Client API HTTP endpoint + +```bash +gcloud endpoints services deploy prime/infra/dev/prime-customer-api.yaml +``` + +## Deploy to Dev cluster + +### Deploy monitoring + +```bash +kubectl apply -f prime/infra/dev/monitoring.yaml + +# If the above command fails on creating clusterroles / clusterbindings you need to add a role to the user you are using to deploy +# You can read more about it here https://github.com/coreos/prometheus-operator/issues/357 +kubectl create clusterrolebinding cluster-admin-binding --clusterrole cluster-admin --user $(gcloud config get-value account) + +# +kubectl apply -f prime/infra/dev/monitoring-pushgateway.yaml +``` + +#### Prometheus dashboard +```bash +kubectl port-forward --namespace=monitoring $(kubectl get pods --namespace=monitoring | grep prometheus-core | awk '{print $1}') 9090 +``` + +#### Grafana dashboard +__`Has own its own load balancer and can be accessed directly. Discuss if this is OK or find and implement a different way of accessing the grafana dashboard.`__ + +Can be accessed directly from external ip +```bash +kubectl get services --namespace=monitoring | grep grafana | awk '{print $4}' +``` + +#### Push gateway +```bash +# Push a metric to pushgateway:8080 (specified in the service declaration for pushgateway) +kubectl run curl-it --image=radial/busyboxplus:curl -i --tty --rm +echo "some_metric 4.71" | curl -v --data-binary @- http://pushgateway:8080/metrics/job/some_job +``` + +### Setup Neo4j + +```bash +kubectl apply -f prime/infra/dev/neo4j.yaml +``` + +Then, import initial data into neo4j using `tools/neo4j-admin-tools`. + +### Deploy prime + +```bash +prime/script/deploy-dev-direct.sh +``` + +OR + +```bash +export GCP_PROJECT_ID="$(gcloud config get-value project -q)" +export SHORT_SHA="$(git log -1 --pretty=format:%h)" + +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} +echo SHORT_SHA=${SHORT_SHA} + +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/prime:${SHORT_SHA} . +docker push eu.gcr.io/${GCP_PROJECT_ID}/prime:${SHORT_SHA} +sed -e s/PRIME_VERSION/${SHORT_SHA}/g prime/infra/dev/prime.yaml | kubectl apply -f - +``` + +## Logs + +Goto `https://console.cloud.google.com/logs/viewer` and advanced search for + +```text +resource.type="container" +resource.labels.cluster_name="dev-cluster" +logName="projects/${GCP_PROJECT_ID}/logs/prime" +``` diff --git a/prime/infra/README.md b/prime/infra/README.md index 643f12657..00e52d2c2 100644 --- a/prime/infra/README.md +++ b/prime/infra/README.md @@ -1,208 +1,178 @@ -# Deploying Prime to Kubernetes +# Prime Deployment -### TL;DR +## TL;DR -* **Option 1:** Run this from project root folder (ostelco-core) on `master` branch +For direct developer deployment for testing, use the [helm-deploy-dev-direct.sh](../script/helm-deploy-dev-direct.sh) shell script. +----- - prime/script/deploy.sh +## Dev deployment -* **Option 2:** Push a tag `prime-X.Y.Z` on `master` branch. +Prime is automatically deployed into the dev cluster through the circleci pipeline. +Important Notes: -=== +- The pipeline deploys Prime in the `dev` namespace. DO NOT modify this deployment. +- `Secrets` are created manually and are NOT accessible across namespaces. +- The `default namespace` is available for developers to deploy their own test instances of Prime. See below for details on how to do this. +- The `pipeline` is triggered by merges to develop. It builds the code, builds a new docker image with a new tag, updates the cloud endpoints (API specs) before it then deploys Prime. +- The `pipeline` uses `helm` to deploy to the dev cluster using [the values file (helm deployment config file)](../../.circleci/prime-dev-values.yaml). See below for details on how to update this file. +## Updating helm deployment config -### Setup +If changes are required to the kubernetes deployment of Prime, you need to edit the [helm values file](../../.circleci/prime-dev-values.yaml) used by the pipeline. -Set variables by doing this in `prime` directory: - - #PROJECT_ID=pantel-2decb +### Adding non-secret environment variables +Non-secret environment variables can be defined in the `env` section of the helm values file. Example: +```yaml +env: + FIREBASE_ROOT_PATH: dev + NEO4J_HOST: neo4j-neo4j.neo4j.svc.cluster.local +``` +### Adding Secrets as environment variables +Secrets are created manually using `kubectl create secret ... -n dev` +> Remember to create the secrets in the `dev` namespace before deployment is triggered. -```bash -export PROJECT_ID="$(gcloud config get-value project -q)" -echo "PROJECT_ID=$PROJECT_ID" -export PRIME_VERSION="$(gradle properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" -echo "PRIME_VERSION=$PRIME_VERSION" -``` +Environment variables from k8s secrets can be defined in the `envFromSecret` section of the helm values file. Example: +```yaml +envFromSecret: + - name: SLACK_WEBHOOK_URI # name of the environment variable that will be exposed to Prime + secretName: slack-secrets # name of the secret which should pre-exist in the namespace + secretKey: slackWebHookUri # name of the k8s secret key to get the value from + - name: ANOTHER_ENV_FROM_SECRET + secretName: myExistingSecret + secretKey: theKeyInsideTheSecret +``` -Reference: - * https://cloud.google.com/endpoints/docs/grpc/get-started-grpc-kubernetes-engine +### Adding Secrets as volumes mounted to specific paths -## Deploying to GKE using GCP Container/Cloud Builder +Secrets are created manually using `kubectl create secret ... -n dev` +> Remember to create the secrets in the `dev` namespace before deployment is triggered. -### Using CLI -In the project (ostelco-core) root folder: +Environment variables from k8s secrets can be defined in the `secretVolumes` section of the helm values file. Example: -```bash -gcloud container builds submit \ - --config prime/prod/cloudbuild.yaml \ - --substitutions TAG_NAME=${PRIME_VERSION},BRANCH_NAME=$(git branch | grep \* | cut -d ' ' -f2) . +```yaml +secretVolumes: + - secretName: "prime-sa-key" # the secret name + containerMountPath: "/secret" # the path in the container where the secret is mounted + # mount a secret on specific path with key projection + - secretName: "simmgr-test-secrets" + containerMountPath: "/certs" + secretKey: idemiaClientCert + secretPath: idemia-client-cert.jks # this is the file name that will appear in the volume ``` -#### Limitations - * Remove .git from `.gcloudignore` and detect branch name and check for uncommitted changes. +### ESP containers config -### Using build trigger - - * A build trigger is configured in GCP Container/Cloud Builder to build and deploy prime to GKE cluster - just by adding a git tag on `master` branch. - * The tag name should be `prime-*` - -#### Limitations - * When using build trigger, the version tag on docker images is `prime-X.Y.Z` instead of `X.Y.Z`. - -### Future Improvements - * Create a custom build docker image. (suggestion by Vihang). - * Run AT as quality gate. (suggestion by Remseth). - * Use it for CI. Currently it is only CD. (suggestion by Remseth). - * Use `git-sha` along/instead with version (suggestion by Håvard). - -### References - * Config: https://cloud.google.com/container-builder/docs/build-config - * Running locally: https://cloud.google.com/container-builder/docs/build-debug-locally - * Cloud builders: https://cloud.google.com/container-builder/docs/cloud-builders - * Customization: https://cloud.google.com/container-builder/docs/create-custom-build-steps - * Optimization: https://cloud.google.com/container-builder/docs/speeding-up-builds - * Custom Github web-hooks: https://cloud.google.com/container-builder/docs/configure-third-party-notifications - * Storing secrets for AT: https://cloud.google.com/container-builder/docs/securing-builds/use-encrypted-secrets-credentials - -## Secrets +Each esp container is defined and configured in its own section. The `esp` section defines the ESP image config (which is common). -```bash -kubectl create secret generic pantel-prod.json --from-file prime/config/pantel-prod.json -kubectl create secret generic imeiDb.csv.zip --from-file imeiDb.csv.zip +```yaml +ocsEsp: + enabled: true # whether to have that esp or not + env: {} # any env vars to pass to the esp container + endpointAddress: ocs.dev.oya.world # the cloud endpoint address + ports: # ports exposed from that esp container. format is: : + http2_port: 9000 ``` -Reference: - * https://cloud.google.com/kubernetes-engine/docs/concepts/secret - * https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-files-from-a-pod - * https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-environment-variables +### Services -## Endpoint +Services are configured in the `services` section of the helm values file. -Generate self-contained protobuf descriptor file - `ocs_descriptor.pb` & `metrics_descriptor.pb` +> It is very important to set `grpcOrHttp2: true` if the service being exposed is a GRPC or HTTP2 service -```bash -pyenv versions -pyenv local 3.5.2 -pip install grpcio grpcio-tools - -python -m grpc_tools.protoc \ - --include_imports \ - --include_source_info \ - --proto_path=ocs-grpc-api/src/main/proto \ - --descriptor_set_out=ocs_descriptor.pb \ - ocs.proto - -python -m grpc_tools.protoc \ - --include_imports \ - --include_source_info \ - --proto_path=analytics-grpc-api/src/main/proto \ - --descriptor_set_out=metrics_descriptor.pb \ - prime_metrics.proto +```yaml +services: + ocs: + name: ocs # service name + type: ClusterIP # k8s service type + port: 80 # the service port + targetPort: 9000 # the target port in the prime container + portName: grpc # the name of the service port + host: ocs.dev.oya.world # the DNS at which this service is reachable. This served by Ambassador. + grpcOrHttp2: true # whether this service exposes an HTTP2 or GRPC service. ``` -Deploy endpoints +### TLS certs -```bash -gcloud endpoints services deploy ocs_descriptor.pb prime/infra/prod/ocs-api.yaml -gcloud endpoints services deploy metrics_descriptor.pb prime/infra/prod/metrics-api.yaml -``` +The TLS certificates are managed by `cert-manager` and are automatically created from the helm chart. TLS creation is configured in the `certs` section of the helm values file. -## Deployment & Service +```yaml +certs: + enabled: true # enabled means create a TLS cert + dnsProvider: dev-clouddns # the DNS provider configuration. This preconfigured and should not be changed. + issuer: letsencrypt-production # the Letsencrypt API to use. letsencrypt-production for valid certs or letsencrypt-staging for invalid staging certs. + tlsSecretName: dev-oya-tls # the name of the secret containing credentials to talk to DNS provider API + hosts: # a list of the hosts to be included in the cert. wildcard domains must be wrapped in single quotes + - '*.dev.oya.world' +``` -Increment the docker image tag (version) for next two steps. - -Build the Docker image (In the folder with Dockerfile) +## Deploying developer test instances -```bash -docker build -t eu.gcr.io/${PROJECT_ID}/prime:${PRIME_VERSION} prime -``` +To avoid pipeline waiting time, you can take a short cut and deploy your feature branch directly into the cluster. -Push to the registry +**Important Notes** +- Developer tests can be done in the `default` namespace. +- Secrets will need to be replicated into the `default` namespace from the `dev` namespace. -```bash -docker push eu.gcr.io/${PROJECT_ID}/prime:${PRIME_VERSION} -``` +> You can copy secrets between namespaces with the following command : `$ kubectl get secret --namespace=dev --export -o yaml | kubectl apply --namespace=default -f - ` -Update the tag (version) of prime's docker image in `infra/prod/prime.yaml`. +**Steps:** -Apply the deployment & service +1. Build the prime docker image from your feature branch and tag it with your custom tag (e.g. eu.gcr.io/pi-ostelco-dev/prime:feature-xyz) +2. Push the built image into the docker registry. ```bash -sed -e s/PRIME_VERSION/${PRIME_VERSION}/g prime/infra/prod/prime.yaml | kubectl apply -f - +# auth is needed since the docker registry is private +$ gcloud auth login +$ docker push eu.gcr.io/pi-ostelco-dev/prime:feature-xyz ``` -Details of the deployment +3. [Install helm](https://helm.sh/docs/using_helm/#install-helm) -```bash -kubectl describe deployment prime -kubectl get pods -``` +4. Make your own copy of the helm values file. -Details of service +Copy the [prime-direct helm values file](prime-direct-values.yaml) and edit the DNS prefix in the `dnsPrefix` section with unique custom prefix (e.g. `feature-xyz-`). Make sure you have a dash at the end of your prefix. -```bash -kubectl describe service prime-service -``` +5. run the following helm commands: -## API Endpoint +> Note: the helm release name must be unique. A good example might be feature name or developer name. -```bash -gcloud endpoints services deploy prime/infra/prod/prime-client-api.yaml -``` +> Note: you can change the helm chart version below to a specific version of the prime helm chart. -## SSL secrets for api.ostelco.org, ocs.ostelco.org & metrics.ostelco.org -The endpoints runtime expects the SSL configuration to be named -as `nginx.crt` and `nginx.key`. Sample command to create the secret: -```bash -kubectl create secret generic api-ostelco-ssl \ - --from-file=./nginx.crt \ - --from-file=./nginx.key +```bash +# the first command is only needed once +$ helm repo add ostelco https://storage.googleapis.com/pi-ostelco-helm-charts-repo/ +$ helm repo update +# if your kube context is not configured to point to the dev cluster, then configure it +$ kubectl config use-context gke_pi-ostelco-dev_europe-west1-c_pi-dev +$ RELEASE_NAME= +$ helm upgrade ${RELEASE_NAME} ostelco/prime --version 0.6.1 --install -f --set prime.tag=feature-xyz ``` -The secret for ... - * `api.ostelco.org` is in `api-ostelco-ssl` - * `ocs.ostelco.org` is in `ocs-ostelco-ssl` - * `metrics.ostelco.org` is in `metrics-ostelco-ssl` +you can then watch for your pods being created with this command: -# For Dev cluster +```bash +$ kubectl get pods -n dev -l release=${RELEASE_NAME} -w +``` -## One time setup +Once your pods are in the `Running` state, you can test the APIs of your custom deployment on: feature-xyz-prime-api-name.test.oya.world (e.g. https://feature-xyz-api.test.oya.world) -### Cluster - * Create cluster +To delete your custom deployment: ```bash -gcloud container clusters create dev-cluster \ - --scopes=default,bigquery,datastore,pubsub,sql,storage-rw \ - --zone=europe-west1-b \ - --num-nodes=1 -``` - * Create node-pool -```bash -gcloud container node-pools create dev-node-pool \ - --cluster=dev-cluster \ - --machine-type=n1-standard-2 \ - --scopes=default,bigquery,datastore,pubsub,sql,storage-rw \ - --num-nodes=3 \ - --zone=europe-west1-b \ - --enable-autorepair -``` - * Delete default pool -```bash -gcloud container node-pools delete default-pool \ - --cluster=dev-cluster \ - --zone=europe-west1-b +$ helm delete --purge ${RELEASE_NAME} ``` + ### Secrets - * Place `*.dev.ostelco.org` cert at `certs/dev.ostelco.org` + * Place certs at `certs/${API_URI}/` * Create k8s secrets + * Use `-n namespace` in `kubectl create secret` if namespace is not _default_ namespace. + ```bash -kubectl create secret generic pantel-prod.json --from-file prime/config/pantel-prod.json +kubectl create secret generic prime-service-account.json --from-file prime/config/prime-service-account.json ``` Note: To update the secrets defined using yaml, delete and created them again. They are not updated. @@ -217,135 +187,112 @@ kubectl create secret generic slack-secrets --from-literal=slackWebHookUri='http ```bash kubectl create secret generic ocs-ostelco-ssl \ - --from-file=certs/dev.ostelco.org/nginx.crt \ - --from-file=certs/dev.ostelco.org/nginx.key + --from-file=certs/${API_URI}/nginx.crt \ + --from-file=certs/${API_URI}/nginx.key ``` ```bash kubectl create secret generic api-ostelco-ssl \ - --from-file=certs/dev.ostelco.org/nginx.crt \ - --from-file=certs/dev.ostelco.org/nginx.key + --from-file=certs/${API_URI}/nginx.crt \ + --from-file=certs/${API_URI}/nginx.key ``` ```bash kubectl create secret generic metrics-ostelco-ssl \ - --from-file=certs/dev.ostelco.org/nginx.crt \ - --from-file=certs/dev.ostelco.org/nginx.key + --from-file=certs/${API_URI}/nginx.crt \ + --from-file=certs/${API_URI}/nginx.key ``` -### Cloud Pub/Sub ```bash -gcloud pubsub topics create purchase-info +kubectl create secret generic jumio-secrets \ + --from-literal=apiToken='jumioApiToken' \ + --from-literal=apiSecret='jumioApiSecret' ``` -### Endpoints - - * OCS gRPC endpoint - -Generate self-contained protobuf descriptor file - ocs_descriptor.pb - ```bash -pip install grpcio grpcio-tools - -python -m grpc_tools.protoc \ - --include_imports \ - --include_source_info \ - --proto_path=ocs-grpc-api/src/main/proto \ - --descriptor_set_out=ocs_descriptor.pb \ - ocs.proto - -python -m grpc_tools.protoc \ - --include_imports \ - --include_source_info \ - --proto_path=analytics-grpc-api/src/main/proto \ - --descriptor_set_out=metrics_descriptor.pb \ - prime_metrics.proto +kubectl create secret generic myinfo-secrets \ + --from-literal=apiClientId='myInfoApiClientId' \ + --from-literal=apiClientSecret='myInfoApiClientSecret' \ + --from-literal=serverPublicKey='myInfoServerPublicKey' \ + --from-literal=clientPrivateKey='myInfoClientPrivateKey' ``` -Deploy endpoints - ```bash -gcloud endpoints services deploy ocs_descriptor.pb prime/infra/dev/ocs-api.yaml -gcloud endpoints services deploy metrics_descriptor.pb prime/infra/dev/metrics-api.yaml +kubectl create secret generic scaninfo-secrets --from-literal=bucketName='bucketname' ``` - * Client API HTTP endpoint - ```bash -gcloud endpoints services deploy prime/infra/dev/prime-client-api.yaml +kubectl create secret generic mandrill-secrets --from-literal=mandrillApiKey='keep-mandrill-api-key-here' ``` -## Deploy to Dev cluster +### Keysets for scan information store -### Deploy monitoring +The keys are generated using `Tinkey` tool provied as part of the google tink project +More information can be found here: https://github.com/google/tink/blob/v1.2.2/docs/TINKEY.md +To create a new private key set for testing ```bash -kubectl apply -f prime/infra/dev/monitoring.yaml - -# If the above command fails on creating clusterroles / clusterbindings you need to add a role to the user you are using to deploy -# You can read more about it here https://github.com/coreos/prometheus-operator/issues/357 -kubectl create clusterrolebinding cluster-admin-binding --clusterrole cluster-admin --user $(gcloud config get-value account) - -# -kubectl apply -f prime/infra/dev/monitoring-pushgateway.yaml +bazel-bin/tools/tinkey/tinkey create-keyset --key-template ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM --out test_keyset_pvt_cltxt ``` - -#### Prometheus dashboard + Generate a public key set from the above private keyset. ```bash -kubectl port-forward --namespace=monitoring $(kubectl get pods --namespace=monitoring | grep prometheus-core | awk '{print $1}') 9090 +bazel-bin/tools/tinkey/tinkey create-public-keyset --in test_keyset_pvt_cltxt --out test_keyset_pub_cltxt ``` -#### Grafana dashboard -__`Has own its own load balancer and can be accessed directly. Discuss if this is OK or find and implement a different way of accessing the grafana dashboard.`__ +The keysets for production (public key only) needs to be encrypted using GCP KMS. More details can be found in docs for `--master-key-uri` option in `tinkey` -Can be accessed directly from external ip +- Create Key ring and master key to be used for encrypting the public keys ```bash -kubectl get services --namespace=monitoring | grep grafana | awk '{print $4}' +gcloud kms keyrings create scan-dev --location global +gcloud kms keys create scan-info --location global --keyring scan-dev --purpose encryption ``` -#### Push gateway +- Set the master key URI for decrypting the keysets. ```bash -# Push a metric to pushgateway:8080 (specified in the service declaration for pushgateway) -kubectl run curl-it --image=radial/busyboxplus:curl -i --tty --rm -echo "some_metric 4.71" | curl -v --data-binary @- http://pushgateway:8080/metrics/job/some_job +kubectl create secret generic scaninfo-keys --from-literal=masterKeyUri='gcp-kms://projects/${GCP_PROJECT_ID}/locations/global/keyRings/scan-dev/cryptoKeys/scan-info' ``` +- Create keysets for an environment. Use `tinkey` to generate +```bash +bazel-bin/tools/tinkey/tinkey create-keyset --key-template ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM --out clear_encrypt_key_global_pvt +bazel-bin/tools/tinkey/tinkey create-public-keyset --in clear_encrypt_key_global_pvt --out clear_encrypt_key_global_pub +bazel-bin/tools/tinkey/tinkey convert-keyset --out encrypt_key_global --in clear_encrypt_key_global_pub \ +--new-master-key-uri gcp-kms://projects/${GCP_PROJECT_ID}/locations/global/keyRings/scan-dev/cryptoKeys/scan-info -### Setup Neo4j - +bazel-bin/tools/tinkey/tinkey create-keyset --key-template ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM --out clear_encrypt_key_sg_pvt +bazel-bin/tools/tinkey/tinkey create-public-keyset --in clear_encrypt_key_sg_pvt --out clear_encrypt_key_sg_pub +bazel-bin/tools/tinkey/tinkey convert-keyset --out encrypt_key_sg --in clear_encrypt_key_sg_pub \ +--new-master-key-uri gcp-kms://projects/${GCP_PROJECT_ID}/locations/global/keyRings/scan-dev/cryptoKeys/scan-info +``` +- Set the encryption keysets (public keys only) as kubernetes secrets. ```bash -kubectl apply -f prime/infra/dev/neo4j.yaml +kubectl create secret generic scaninfo-keysets --from-file=./encrypt_key_global --from-file=./encrypt_key_sg --namespace dev ``` - -Then, import initial data into neo4j using `tools/neo4j-admin-tools`. - -### Deploy prime - +Prime will use CloudKMS (through tink library) to decrypt the keysets. It requires an IAM role to enable these APIs. ```bash -prime/script/deploy-dev-direct.sh +gcloud projects add-iam-policy-binding ${GCP_PROJECT_ID} --member serviceAccount:prime-service-account@${GCP_PROJECT_ID}.iam.gserviceaccount.com --role roles/cloudkms.cryptoKeyEncrypterDecrypter ``` -OR +### Cloud Pub/Sub ```bash -export PROJECT_ID="$(gcloud config get-value project -q)" -export SHORT_SHA="$(git log -1 --pretty=format:%h)" +gcloud pubsub topics create active-users +gcloud pubsub topics create data-traffic +gcloud pubsub topics create purchase-info -echo PROJECT_ID=${PROJECT_ID} -echo SHORT_SHA=${SHORT_SHA} +gcloud pubsub topics create stripe-event -docker build -t eu.gcr.io/${PROJECT_ID}/prime:${SHORT_SHA} . -docker push eu.gcr.io/${PROJECT_ID}/prime:${SHORT_SHA} -sed -e s/PRIME_VERSION/${SHORT_SHA}/g prime/infra/dev/prime.yaml | kubectl apply -f - -``` +gcloud pubsub topics create ocs-ccr +gcloud pubsub topics create ocs-cca +gcloud pubsub topics create ocs-activate -## Logs -Goto `https://console.cloud.google.com/logs/viewer` and advanced search for +gcloud pubsub subscriptions create stripe-event-store-sub --topic=stripe-event --topic-project=${GCP_PROJECT_ID} +gcloud pubsub subscriptions create stripe-event-report-sub --topic=stripe-event --topic-project=${GCP_PROJECT_ID} +gcloud pubsub subscriptions create stripe-event-recurring-payment-sub --topic=stripe-event --topic-project=${GCP_PROJECT_ID} -```text -resource.type="container" -resource.labels.cluster_name="dev-cluster" -logName="projects/pantel-2decb/logs/prime" +gcloud pubsub subscriptions create ocs-ccr-sub --topic=ocs-ccr --topic-project=${GCP_PROJECT_ID} +gcloud pubsub subscriptions create ocsgw-cca-sub --topic=ocs-cca --topic-project=${GCP_PROJECT_ID} +gcloud pubsub subscriptions create ocsgw-activate-sub --topic=ocs-activate --topic-project=${GCP_PROJECT_ID} ``` ## Connect using Neo4j Browser @@ -360,8 +307,8 @@ gcloud dataflow jobs run active-users-dev \ --gcs-location gs://dataflow-templates/latest/PubSub_to_BigQuery \ --region europe-west1 \ --parameters \ -inputTopic=projects/pantel-2decb/topics/active-users-dev,\ -outputTableSpec=pantel-2decb:ocs_gateway_dev.raw_activeusers +inputTopic=projects/${GCP_PROJECT_ID}/topics/active-users-dev,\ +outputTableSpec=${GCP_PROJECT_ID}:ocs_gateway_dev.raw_activeusers # For production cluster @@ -369,8 +316,8 @@ gcloud dataflow jobs run active-users \ --gcs-location gs://dataflow-templates/latest/PubSub_to_BigQuery \ --region europe-west1 \ --parameters \ -inputTopic=projects/pantel-2decb/topics/active-users,\ -outputTableSpec=pantel-2decb:ocs_gateway.raw_activeusers +inputTopic=projects/${GCP_PROJECT_ID}/topics/active-users,\ +outputTableSpec=${GCP_PROJECT_ID}:ocs_gateway.raw_activeusers ``` @@ -382,8 +329,8 @@ gcloud dataflow jobs run purchase-records-dev \ --gcs-location gs://dataflow-templates/latest/PubSub_to_BigQuery \ --region europe-west1 \ --parameters \ -inputTopic=projects/pantel-2decb/topics/purchase-info-dev,\ -outputTableSpec=pantel-2decb:purchases_dev.raw_purchases +inputTopic=projects/${GCP_PROJECT_ID}/topics/purchase-info-dev,\ +outputTableSpec=${GCP_PROJECT_ID}:purchases_dev.raw_purchases # For production cluster @@ -391,7 +338,7 @@ gcloud dataflow jobs run purchase-records \ --gcs-location gs://dataflow-templates/latest/PubSub_to_BigQuery \ --region europe-west1 \ --parameters \ -inputTopic=projects/pantel-2decb/topics/purchase-info,\ -outputTableSpec=pantel-2decb:purchases.raw_purchases +inputTopic=projects/${GCP_PROJECT_ID}/topics/purchase-info,\ +outputTableSpec=${GCP_PROJECT_ID}:purchases.raw_purchases -``` \ No newline at end of file +``` diff --git a/prime/infra/dev/metrics-api.yaml b/prime/infra/dev/metrics-api.yaml index fafd2ff79..6a8973494 100644 --- a/prime/infra/dev/metrics-api.yaml +++ b/prime/infra/dev/metrics-api.yaml @@ -2,7 +2,7 @@ type: google.api.Service config_version: 3 -name: metrics.dev.ostelco.org +name: metrics.dev.oya.world title: Prime Metrics Reporter Service gRPC API @@ -18,12 +18,12 @@ usage: authentication: providers: - id: google_service_account - issuer: prime-service-account@pantel-2decb.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@pantel-2decb.iam.gserviceaccount.com + issuer: prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com + jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com audiences: > - https://metrics.dev.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - metrics.dev.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - metrics.dev.ostelco.org + https://metrics.dev.oya.world/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, + metrics.dev.oya.world/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, + metrics.dev.oya.world rules: - selector: "*" requirements: diff --git a/prime/infra/dev/neo4j.yaml b/prime/infra/dev/neo4j.yaml deleted file mode 100644 index e647ca8d5..000000000 --- a/prime/infra/dev/neo4j.yaml +++ /dev/null @@ -1,95 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: neo4j - labels: - app: neo4j - component: core -spec: - clusterIP: None - ports: - - port: 7474 - targetPort: 7474 - name: browser - - port: 6362 - targetPort: 6362 - name: backup - selector: - app: neo4j - component: core ---- -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: faster -provisioner: kubernetes.io/gce-pd -parameters: - type: pd-ssd ---- -apiVersion: "apps/v1beta1" -kind: StatefulSet -metadata: - name: neo4j-core -spec: - serviceName: neo4j - replicas: 3 - template: - metadata: - labels: - app: neo4j - component: core - spec: - containers: - - name: neo4j - image: "neo4j:3.4.8-enterprise" - imagePullPolicy: "IfNotPresent" - env: - - name: NEO4J_dbms_mode - value: CORE - - name: NUMBER_OF_CORES - value: "3" - - name: NEO4J_dbms_security_auth__enabled - value: "false" - - name: NEO4J_causal__clustering_discovery__type - value: DNS - - name: NEO4J_causal__clustering_initial__discovery__members - value: "neo4j.default.svc.cluster.local:5000" - - name: NEO4J_ACCEPT_LICENSE_AGREEMENT - value: "yes" - command: - - "/bin/bash" - - "-ecx" - - | - export NEO4J_dbms_connectors_default__advertised__address=$(hostname -f) - export NEO4J_causal__clustering_discovery__advertised__address=$(hostname -f):5000 - export NEO4J_causal__clustering_transaction__advertised__address=$(hostname -f):6000 - export NEO4J_causal__clustering_raft__advertised__address=$(hostname -f):7000 - exec /docker-entrypoint.sh "neo4j" - ports: - - containerPort: 5000 - name: discovery - - containerPort: 7000 - name: raft - - containerPort: 6000 - name: tx - - containerPort: 7474 - name: browser - - containerPort: 7687 - name: bolt - - containerPort: 6362 - name: backup - securityContext: - privileged: true - volumeMounts: - - name: datadir - mountPath: "/data" - volumeClaimTemplates: - - metadata: - name: datadir - spec: - accessModes: - - ReadWriteOnce - storageClassName: "faster" - resources: - requests: - storage: "10Gi" \ No newline at end of file diff --git a/prime/infra/dev/ocs-api.yaml b/prime/infra/dev/ocs-api.yaml index 2e097985e..ccb3860d2 100644 --- a/prime/infra/dev/ocs-api.yaml +++ b/prime/infra/dev/ocs-api.yaml @@ -2,7 +2,7 @@ type: google.api.Service config_version: 3 -name: ocs.dev.ostelco.org +name: ocs.dev.oya.world title: OCS Service gRPC API @@ -18,13 +18,13 @@ usage: authentication: providers: - id: google_service_account - issuer: prime-service-account@pantel-2decb.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@pantel-2decb.iam.gserviceaccount.com + issuer: prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com + jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com audiences: > - https://ocs.dev.ostelco.org/org.ostelco.ocs.api.OcsService, - ocs.dev.ostelco.org/org.ostelco.ocs.api.OcsService, - ocs.dev.ostelco.org + https://ocs.dev.oya.world/org.ostelco.ocs.api.OcsService, + ocs.dev.oya.world/org.ostelco.ocs.api.OcsService, + ocs.dev.oya.world rules: - selector: "*" requirements: - - provider_id: google_service_account + - provider_id: google_service_account \ No newline at end of file diff --git a/prime/infra/dev/prime-admin-api.yaml b/prime/infra/dev/prime-admin-api.yaml deleted file mode 100644 index 236f711aa..000000000 --- a/prime/infra/dev/prime-admin-api.yaml +++ /dev/null @@ -1,22 +0,0 @@ -swagger: "2.0" - # THis is where this is going - # https://github.com/GoogleCloudPlatform/golang-samples/blob/master/endpoints/getting-started/openapi.yaml -info: - title: "Offer definition input" - description: "Input definitions of offers, products and segments for the consumption engine" - version: "1.0.0" -# This field will be replaced by the deploy_api.sh script. -# host: "YOUR-PROJECT-ID.appspot.com" -host: "import.endpoints.pantel-2decb.cloud.goog" -schemes: - - "https" -paths: - "/import/status": - get: - description: "If the status service is available, then return 200." - operationId: "getStatus" - responses: - 200: - description: "Success." - 400: - description: "Import service not available" diff --git a/prime/infra/dev/prime-client-api.yaml b/prime/infra/dev/prime-client-api.yaml deleted file mode 100644 index 4038789bc..000000000 --- a/prime/infra/dev/prime-client-api.yaml +++ /dev/null @@ -1,598 +0,0 @@ -swagger: "2.0" -info: - title: "Ostelco API" - description: "The client API for Panacea." - version: "1.0.0" -host: "api.dev.ostelco.org" -x-google-endpoints: - - name: "api.dev.ostelco.org" - allowCors: true -schemes: - - "https" -paths: - "/profile": - get: - description: "Get profile for the user (email-id present in the bearer token)." - produces: - - application/json - operationId: "getProfile" - responses: - 200: - description: "Get the profile for this user." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - post: - description: "Create a new profile." - consumes: - - application/json - produces: - - application/json - operationId: "createProfile" - parameters: - - name: profile - in: body - description: The profile to create. - schema: - $ref: '#/definitions/Profile' - - name: referred_by - in: query - description: "Referral ID of user who has invited this user" - type: string - responses: - 201: - description: "Successfully created the profile." - schema: - $ref: '#/definitions/Profile' - security: - - auth0_jwt: [] - put: - description: "Update an existing profile." - consumes: - - application/json - produces: - - application/json - operationId: "updateProfile" - parameters: - - in: body - name: profile - description: The updated profile. - schema: - $ref: '#/definitions/Profile' - responses: - 200: - description: "Successfully updated the profile." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - "/applicationtoken": - post: - description: "Store application token" - consumes: - - application/json - produces: - - application/json - operationId: "storeApplicationToken" - parameters: - - name: applicationToken - in: body - description: application token - schema: - $ref: '#/definitions/ApplicationToken' - responses: - 201: - description: "Successfully stored token." - schema: - $ref: '#/definitions/ApplicationToken' - 404: - description: "User not found." - 507: - description: "Not able to store token." - security: - - auth0_jwt: [] - "/paymentSources": - get: - description: "Get all payment sources for the user." - produces: - - application/json - operationId: "listSources" - responses: - 200: - description: "List of payment sources." - schema: - $ref: '#/definitions/PaymentSourceList' - 404: - description: "No user found." - security: - - auth0_jwt: [] - post: - description: "Add a new payment source for user" - produces: - - application/json - operationId: "createSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be added to user" - required: true - type: string - responses: - 201: - description: "Successfully added source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - put: - description: "Set the source as default for user" - produces: - - application/json - operationId: "setDefaultSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the default source" - required: true - type: string - responses: - 200: - description: "Successfully set as default source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - delete: - description: "Remove a payment source for user" - produces: - - application/json - operationId: "removeSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be removed" - required: true - type: string - responses: - 200: - description: "Successfully removed the source" - schema: - $ref: '#/definitions/PaymentSource' - 400: - description: "The source could not be removed" - 404: - description: "No such source for user" - security: - - auth0_jwt: [] - "/products": - get: - description: "Get all products for the user." - produces: - - application/json - operationId: "getAllProducts" - responses: - 200: - description: "List of products." - schema: - $ref: '#/definitions/ProductList' - 404: - description: "No products found for the user." - security: - - auth0_jwt: [] - "/products/{sku}": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "buyProductDeprecated" - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - parameters: - - name: sku - in: path - description: SKU to be purchased - required: true - type: string - "/products/{sku}/purchase": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "purchaseProduct" - parameters: - - name: sku - in: path - description: "SKU to be purchased" - required: true - type: string - - name: sourceId - in: query - description: "The stripe-id of the source to be used for this purchase (if empty, use default source)" - required: false - type: string - - name: saveCard - in: query - description: "Whether to save this card as a source for this user (default = false)" - required: false - type: boolean - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - "/purchases": - get: - description: "Get list of all purchases." - produces: - - application/json - - text/plain - operationId: "getPurchaseHistory" - responses: - 200: - description: "List of Purchase Records." - schema: - $ref: '#/definitions/PurchaseRecordList' - 404: - description: "No Purchase Records found for the user." - security: - - auth0_jwt: [] - "/subscriptions": - get: - description: "Get subscription (msisdn) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptions" - responses: - 200: - description: "Get subscriptions for this user." - schema: - $ref: '#/definitions/SubscriptionList' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/bundles": - get: - description: "Get bundles (balance) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getBundles" - responses: - 200: - description: "Get bundles for this user." - schema: - $ref: '#/definitions/BundleList' - 404: - description: "No bundle found for this user." - security: - - auth0_jwt: [] - "/subscription/status": - get: - description: "Get subscription status for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptionStatus" - responses: - 200: - description: "Get the subscription status for this user." - schema: - $ref: '#/definitions/SubscriptionStatus' - 404: - description: "No subscription status found for this user." - security: - - auth0_jwt: [] - "/subscription/activePseudonyms": - get: - description: "Get currently active pseudonyms for the user's msisdn (identified by bearer token)." - produces: - - application/json - operationId: "getActivePseudonyms" - responses: - 200: - description: "Get active pseudonyms for the user's msisdn." - schema: - $ref: '#/definitions/ActivePseudonyms' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/referred": - get: - description: "Get list of people whom the user has referred to." - produces: - - application/json - operationId: "getReferred" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/PersonList' - 404: - description: "No referrals found for this user." - security: - - auth0_jwt: [] - "/referred/by": - get: - description: "Get the people who had referred this user." - produces: - - application/json - operationId: "getReferredBy" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/Person' - 404: - description: "No 'referred by' found for this user." - security: - - auth0_jwt: [] - "/consents": - get: - description: "Get all consents for the user." - produces: - - application/json - operationId: "getConsents" - responses: - 200: - description: "List of consents." - schema: - $ref: '#/definitions/ConsentList' - 404: - description: "No consents found for the user." - security: - - auth0_jwt: [] - "/consents/{consent-id}": - put: - description: "Change the value for the specified consent." - operationId: "updateConsent" - responses: - 200: - description: "Successfully updated the consent." - 404: - description: "Consent not found." - security: - - auth0_jwt: [] - parameters: - - name: consent-id - in: path - description: "Id of the consent to be changed" - required: true - type: string - - name: accepted - in: query - description: "Whether user accepted the consent (default = true)" - required: false - type: boolean -definitions: - Profile: - type: object - properties: - name: - type: string - address: - type: string - postCode: - type: string - city: - type: string - country: - type: string - email: - type: string - format: email - referralId: - type: string - required: - - email - SubscriptionList: - type: array - items: - $ref: '#/definitions/Subscription' - Subscription: - type: object - properties: - msisdn: - description: "Mobile number for this subscription" - type: string - BundleList: - type: array - items: - $ref: '#/definitions/Bundle' - Bundle: - type: object - properties: - id: - description: "Bundle ID" - type: string - balance: - description: "Balance units in this bundle" - type: integer - format: int64 - SubscriptionStatus: - type: object - properties: - remaining: - description: "Remaining data" - type: integer - format: int64 - purchaseRecords: - description: "List of Purchases" - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecordList: - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecord: - type: object - properties: - id: - description: "Purchase Record ID" - type: string - msisdn: - description: "Deprecated: The MSISDN for which the purchase was made." - type: string - timestamp: - description: "The time stamp of the purchase" - type: integer - format: int64 - product: - $ref: '#/definitions/Product' - required: - - timestamp - - product - ProductList: - type: array - items: - $ref: '#/definitions/Product' - Product: - type: object - properties: - sku: - description: "A unique Id representing a SKU" - type: string - price: - $ref: '#/definitions/Price' - properties: - type: object - presentation: - type: object - required: - - sku - - price - PaymentSourceList: - type: array - items: - $ref: '#/definitions/PaymentSource' - PaymentSource: - type: object - properties: - id: - description: "The identifier for the source" - type: string - type: - description: "The type of source" - type: string - details: - description: "All information stored with the source" - type: object - additionalProperties: true - required: - - id - - type - ConsentList: - type: array - items: - $ref: '#/definitions/Consent' - Consent: - type: object - properties: - consentId: - description: "The identifier of the consent" - type: string - description: - description: "A description of the consent" - type: string - accepted: - description: "Whether user has accepted the consent or not" - type: boolean - Price: - type: object - properties: - amount: - description: "A positive integer in the smallest currency unit" - type: integer - minimum: 0 - currency: - description: "ISO 4217 currency code (three letter alphabetic code)" - type: string - required: - - amount - - currency - ApplicationToken: - type: object - properties: - token: - description: "Application token" - type: string - applicationID: - description: "Uniquely identifier for the app instance" - type: string - tokenType: - description: "Type of application token (FCM)" - type: string - required: - - token - - applicationID - PseudonymEntity: - type: object - properties: - sourceId: - type: string - pseudonym: - type: string - start: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - end: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - required: - - sourceId - - pseudonym - - start - - end - Person: - type: object - properties: - name: - type: string - required: - - name - PersonList: - type: array - items: - $ref: '#/definitions/Person' - ActivePseudonyms: - type: object - properties: - current: - $ref: '#/definitions/PseudonymEntity' - next: - $ref: '#/definitions/PseudonymEntity' - required: - - current - - next -securityDefinitions: - auth0_jwt: - authorizationUrl: "https://ostelco.eu.auth0.com/authorize" - flow: "implicit" - type: "oauth2" - x-google-issuer: "https://ostelco.eu.auth0.com/" - x-google-jwks_uri: "https://ostelco.eu.auth0.com/.well-known/jwks.json" - x-google-audiences: "http://google_api" \ No newline at end of file diff --git a/prime/infra/dev/prime-customer-api.yaml b/prime/infra/dev/prime-customer-api.yaml new file mode 100644 index 000000000..c7813f599 --- /dev/null +++ b/prime/infra/dev/prime-customer-api.yaml @@ -0,0 +1,968 @@ +swagger: "2.0" +info: + title: "Ostelco API" + description: "The customer API." + version: "1.0.0" +host: "api.dev.oya.world" +x-google-endpoints: + - name: "api.dev.oya.world" + allowCors: true +schemes: + - "https" +paths: + "/customer/stripe-ephemeral-key": + get: + description: "Get Stripe Ephemeral key." + produces: + - application/json + operationId: "getStripeEphemeralKey" + parameters: + - name: api_version + in: query + description: "Stripe API version" + type: string + format: email + responses: + 200: + description: "Get Stripe Ephemeral key." + schema: + type: string + security: + - auth0_jwt: [] + - firebase: [] + "/context": + get: + description: "Get context which is customer and region details." + produces: + - application/json + operationId: "getContext" + responses: + 200: + description: "Get the customer context." + schema: + $ref: '#/definitions/Context' + 404: + description: "Customer not found." + security: + - auth0_jwt: [] + - firebase: [] + "/customer": + get: + description: "Get customer info (email-id present in the bearer token)." + produces: + - application/json + operationId: "getCustomer" + responses: + 200: + description: "Get the customer info." + schema: + $ref: '#/definitions/Customer' + 404: + description: "Customer not found." + security: + - auth0_jwt: [] + - firebase: [] + post: + description: "Create a new customer." + consumes: + - application/json + produces: + - application/json + operationId: "createCustomer" + parameters: + - name: nickname + in: query + description: "Nickname of the customer" + type: string + required: true + - name: contactEmail + in: query + description: "Contact Email of the customer" + type: string + required: true + - name: referredBy + in: query + description: "Referral ID of user who has invited this user" + type: string + responses: + 201: + description: "Successfully created the customer." + schema: + $ref: '#/definitions/Customer' + 400: + description: "Incomplete customer info" + 500: + description: "Failed to store customer" + security: + - auth0_jwt: [] + - firebase: [] + put: + description: "Update an existing customer." + consumes: + - application/json + produces: + - application/json + operationId: "updateCustomer" + parameters: + - name: nickname + in: query + description: "Nickname of the customer" + type: string + - name: contactEmail + in: query + description: "Contact Email of the customer" + type: string + responses: + 200: + description: "Successfully updated the customer." + schema: + $ref: '#/definitions/Customer' + 400: + description: "Incomplete Customer info." + 404: + description: "Customer not found." + 500: + description: "Failed to update customer info." + security: + - auth0_jwt: [] + - firebase: [] + delete: + description: "Remove customer." + produces: + - application/json + operationId: "removeCustomer" + responses: + 204: + description: "Remove customer." + 404: + description: "Customer not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions": + get: + description: "Get all regions and region details like SIM Profiles for that region." + produces: + - application/json + operationId: "getAllRegions" + responses: + 200: + description: "List of all Region Details" + schema: + $ref: '#/definitions/RegionDetailsList' + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}": + get: + description: "Get region details like SIM Profiles for a region." + produces: + - application/json + operationId: "getRegion" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 200: + description: "Region Details for a region" + schema: + $ref: '#/definitions/RegionDetails' + 404: + description: "Region not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/kyc/jumio/scans": + post: + description: "Get a new Id for eKYC scanning." + produces: + - application/json + operationId: "createNewJumioKycScanId" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 201: + description: "Successfully retrieved new ScanId." + schema: + $ref: '#/definitions/ScanInformation' + 404: + description: "Region not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/kyc/jumio/scans/{scanId}": + get: + description: "Get status of eKYC scan." + produces: + - application/json + operationId: "getScan" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + - name: scanId + in: path + description: "Id of the scan being queried" + required: true + type: string + responses: + 200: + description: "Successfully retrieved Scan information." + schema: + $ref: '#/definitions/ScanInformation' + 404: + description: "Region or Scan not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/sg/kyc/myInfo/{authorisationCode}": + get: + description: "Get Customer Data from Singapore MyInfo service." + produces: + - application/json + operationId: "getCustomerMyInfoData" + parameters: + - name: authorisationCode + in: path + description: "Authorisation Code" + required: true + type: string + responses: + 200: + description: "Successfully retrieved Customer Data from MyInfo service." + schema: + type: object + 404: + description: "Person Data not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/sg/kyc/dave/{nricFinId}": + get: + description: "Get Customer Data from Singapore MyInfo service." + produces: + - application/json + operationId: "checkNricFinId" + parameters: + - name: nricFinId + in: path + description: "NRIC/FIN ID for Singapore" + required: true + type: string + responses: + 204: + description: "Successfully verified Singapore's NRIC/FIN ID for the Customer." + 400: + description: "Invalid NRIC/FIN ID" + security: + - auth0_jwt: [] + - firebase: [] + "/regions/sg/kyc/profile": + put: + description: "Update Singapore Customer's address and phone number." + produces: + - application/json + operationId: "updateDetails" + parameters: + - name: address + in: query + description: "Customer's Address" + required: true + type: string + - name: phoneNumber + in: query + description: "Customer's phone number" + required: true + type: string + responses: + 204: + description: "Successfully updated customer's details." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/simProfiles": + get: + description: "Get SIM profile for the user (identified by bearer token)." + produces: + - application/json + operationId: "getSimProfiles" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 200: + description: "Get SIM profiles for this user." + schema: + $ref: '#/definitions/SimProfileList' + 404: + description: "Not allowed for this region, or No SIM profiles found for this user for this region." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + post: + description: "Provision SIM Profile for the user (identified by bearer token)." + produces: + - application/json + operationId: "provisionSimProfile" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + - name: profileType + in: query + description: "Profile Type" + type: string + responses: + 201: + description: "Provisioned SIM profile for this user." + schema: + $ref: '#/definitions/SimProfile' + 400: + description: "Not allowed for this region, or missing parameters." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/subscriptions": + get: + description: "Get subscription (msisdn) for the user (identified by bearer token)." + produces: + - application/json + operationId: "getSubscriptionsForRegion" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 200: + description: "Get subscriptions for a region for this user." + schema: + $ref: '#/definitions/SubscriptionList' + 404: + description: "No subscription found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/subscriptions": + get: + description: "Get subscription (msisdn) for the user (identified by bearer token)." + produces: + - application/json + operationId: "getSubscriptions" + responses: + 200: + description: "Get subscriptions for this user." + schema: + $ref: '#/definitions/SubscriptionList' + 404: + description: "No subscription found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/applicationToken": + post: + description: "Store application token" + consumes: + - application/json + produces: + - application/json + operationId: "storeApplicationToken" + parameters: + - name: applicationToken + in: body + description: application token + schema: + $ref: '#/definitions/ApplicationToken' + responses: + 201: + description: "Successfully stored token." + schema: + $ref: '#/definitions/ApplicationToken' + 400: + description: "Token malformed. Not able to store" + 404: + description: "User not found." + 500: + description: "Not able to store token." + security: + - auth0_jwt: [] + - firebase: [] + "/paymentSources": + get: + description: "Get all payment sources for the user." + produces: + - application/json + operationId: "listSources" + responses: + 200: + description: "List of payment sources." + schema: + $ref: '#/definitions/PaymentSourceList' + 404: + description: "No user found." + 503: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + post: + description: "Add a new payment source for user" + produces: + - application/json + operationId: "createSource" + parameters: + - name: sourceId + in: query + description: "The stripe-id of the source to be added to user" + required: true + type: string + responses: + 201: + description: "Successfully added source to user" + schema: + $ref: '#/definitions/PaymentSource' + 400: + description: "Invalid source" + 404: + description: "User not found." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + put: + description: "Set the source as default for user" + produces: + - application/json + operationId: "setDefaultSource" + parameters: + - name: sourceId + in: query + description: "The stripe-id of the default source" + required: true + type: string + responses: + 200: + description: "Successfully set as default source to user" + schema: + $ref: '#/definitions/PaymentSource' + 400: + description: "Invalid source" + 404: + description: "User not found." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + delete: + description: "Remove a payment source for user" + produces: + - application/json + operationId: "removeSource" + parameters: + - name: sourceId + in: query + description: "The stripe-id of the source to be removed" + required: true + type: string + responses: + 200: + description: "Successfully removed the source" + schema: + $ref: '#/definitions/PaymentSource' + 400: + description: "Invalid source, or The source could not be removed" + 404: + description: "No such source for user" + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + "/products": + get: + description: "Get all products for the user." + produces: + - application/json + operationId: "getAllProducts" + responses: + 200: + description: "List of products." + schema: + $ref: '#/definitions/ProductList' + 404: + description: "No products found for the user." + security: + - auth0_jwt: [] + - firebase: [] + "/products/{sku}/purchase": + post: + description: "Buy the product specified in sku parameter." + produces: + - application/json + - text/plain + operationId: "purchaseProduct" + parameters: + - name: sku + in: path + description: "SKU to be purchased" + required: true + type: string + - name: sourceId + in: query + description: "The stripe-id of the source to be used for this purchase (if empty, use default source)" + required: false + type: string + - name: saveCard + in: query + description: "Whether to save this card as a source for this user (default = false)" + required: false + type: boolean + responses: + 201: + description: "Successfully purchased the product." + schema: + $ref: '#/definitions/Product' + 404: + description: "Product not found." + security: + - auth0_jwt: [] + - firebase: [] + "/purchases": + get: + description: "Get list of all purchases." + produces: + - application/json + - text/plain + operationId: "getPurchaseHistory" + responses: + 200: + description: "List of Purchase Records." + schema: + $ref: '#/definitions/PurchaseRecordList' + 400: + description: "Not allowed to charge this source" + 404: + description: "No Purchase Records found for the user." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + "/bundles": + get: + description: "Get bundles (balance) for the user (identified by bearer token)." + produces: + - application/json + operationId: "getBundles" + responses: + 200: + description: "Get bundles for this user." + schema: + $ref: '#/definitions/BundleList' + 404: + description: "No bundle found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/referred": + get: + description: "Get list of people whom the user has referred to." + produces: + - application/json + operationId: "getReferred" + responses: + 200: + description: "List of people whom this person has referred to." + schema: + $ref: '#/definitions/PersonList' + 404: + description: "No referrals found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/referred/by": + get: + description: "Get the people who had referred this user." + produces: + - application/json + operationId: "getReferredBy" + responses: + 200: + description: "List of people whom this person has referred to." + schema: + $ref: '#/definitions/Person' + 404: + description: "No 'referred by' found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/graphql": + post: + description: "GraphQL endpoint" + consumes: + - application/json + produces: + - application/json + operationId: "graphql" + responses: + 200: + description: "Success" + schema: + type: object + 404: + description: "Not found" + security: + - auth0_jwt: [] + - firebase: [] + parameters: + - name: "request" + in: body + description: "GraphQL Request." + schema: + $ref: '#/definitions/GraphQLRequest' + +definitions: + Context: + type: object + properties: + customer: + $ref: '#/definitions/Customer' + regions: + $ref: '#/definitions/RegionDetailsList' + Customer: + type: object + properties: + id: + type: string + nickname: + type: string + contactEmail: + type: string + format: email + analyticsId: + type: string + referralId: + type: string + required: + - name + - email + RegionDetailsList: + type: array + items: + $ref: '#/definitions/RegionDetails' + RegionDetails: + type: object + properties: + region: + $ref: '#/definitions/Region' + status: + description: "Customer Status for this region" + type: string + enum: [ PENDING, APPROVED ] + kycStatusMap: + description: "Map of status for each KYC" + type: object + properties: + kycType: + $ref: '#/definitions/KycType' + additionalProperties: + $ref: '#/definitions/KycStatus' + example: + JUMIO: PENDING + MY_INFO: APPROVED + NRIC_FIN: REJECTED + ADDRESS_AND_PHONE_NUMBER: PENDING + simProfiles: + $ref: '#/definitions/SimProfileList' + KycType: + type: string + enum: [ JUMIO, MY_INFO, NRIC_FIN, ADDRESS_AND_PHONE_NUMBER ] + KycStatus: + type: string + enum: [ PENDING, REJECTED, APPROVED ] + Region: + type: object + properties: + id: + type: string + name: + type: string + SubscriptionList: + type: array + items: + $ref: '#/definitions/Subscription' + Subscription: + type: object + properties: + msisdn: + description: "Mobile number for this subscription" + type: string + BundleList: + type: array + items: + $ref: '#/definitions/Bundle' + Bundle: + type: object + properties: + id: + description: "Bundle ID" + type: string + balance: + description: "Balance units in this bundle" + type: integer + format: int64 + PurchaseRecordList: + type: array + items: + $ref: '#/definitions/PurchaseRecord' + PurchaseRecord: + type: object + properties: + id: + description: "Purchase Record ID" + type: string + msisdn: + description: "Deprecated: The MSISDN for which the purchase was made." + type: string + timestamp: + description: "The time stamp of the purchase" + type: integer + format: int64 + product: + $ref: '#/definitions/Product' + refund: + $ref: '#/definitions/Refund' + required: + - timestamp + - product + ProductList: + type: array + items: + $ref: '#/definitions/Product' + Product: + type: object + properties: + sku: + description: "A unique Id representing a SKU" + type: string + price: + $ref: '#/definitions/Price' + properties: + type: object + presentation: + type: object + required: + - sku + - price + ProductInfo: + type: object + properties: + id: + description: "A unique Id representing a SKU" + type: string + required: + - id + Refund: + type: object + properties: + id: + description: "A unique Id representing a refund object" + type: string + reason: + description: "Reason provided while refunding" + type: string + timestamp: + description: "The time stamp of the refund" + type: integer + format: int64 + required: + - id + - reason + - timestamp + PaymentSourceList: + type: array + items: + $ref: '#/definitions/PaymentSource' + PaymentSource: + type: object + properties: + id: + description: "The identifier for the source" + type: string + type: + description: "The type of source" + type: string + details: + description: "All information stored with the source" + type: object + additionalProperties: true + required: + - id + - type + Price: + type: object + properties: + amount: + description: "A positive integer in the smallest currency unit" + type: integer + minimum: 0 + currency: + description: "ISO 4217 currency code (three letter alphabetic code)" + type: string + required: + - amount + - currency + ApplicationToken: + type: object + properties: + token: + description: "Application token" + type: string + applicationID: + description: "Uniquely identifier for the app instance" + type: string + tokenType: + description: "Type of application token (FCM)" + type: string + required: + - token + - applicationID + Person: + type: object + properties: + name: + type: string + required: + - name + PersonList: + type: array + items: + $ref: '#/definitions/Person' + Plan: + type: object + properties: + name: + description: "An unique name representing the plan" + type: string + price: + $ref: '#/definitions/Price' + interval: + description: "The recurring period for the plan" + type: string + enum: [ day, week, month, year ] + intervalCount: + description: "Number of intervals in a period" + type: integer + default: 1 + minimum: 1 + properties: + description: "Free form key/value pairs" + type: object + additionalProperties: true + presentation: + description: "Pretty print version of plan" + type: object + additionalProperties: true + required: + - name + - price + - interval + PlanList: + type: array + items: + $ref: '#/definitions/Plan' + GraphQLRequest: + type: object + properties: + query: + description: "GraphQL query." + type: string + operationName: + description: "GraphQL Operation Name." + type: string + variables: + description: "GraphQL query variables." + type: object + ScanInformationList: + type: array + items: + $ref: '#/definitions/ScanInformation' + ScanInformation: + type: object + properties: + scanId: + description: "New scan Id for eKYC" + type: string + regionCode: + description: "Region code" + type: string + status: + description: "The status of the scan" + type: string + scanResult: + description: "The result from the vendor" + type: object + required: + - scanId + - status + SimProfileList: + type: array + items: + $ref: '#/definitions/SimProfile' + SimProfile: + type: object + properties: + iccId: + description: "ID of Sim Profile" + type: string + eSimActivationCode: + description: "eSIM activation code" + type: string + status: + description: "The status of the SIM profile, e.g. INSTALLED" + type: string + enum: [ NOT_READY, AVAILABLE_FOR_DOWNLOAD, DOWNLOADED, INSTALLED, ENABLED ] + alias: + description: "Human readable optional alias for this subscription" + type: string + required: + - iccId + - activationCode + - status +securityDefinitions: + auth0_jwt: + authorizationUrl: "" + flow: "implicit" + type: "oauth2" + x-google-issuer: "https://auth-dev.oya.world/" + x-google-jwks_uri: "https://auth-dev.oya.world/.well-known/jwks.json" + x-google-audiences: "http://google_api" + firebase: + authorizationUrl: "" + flow: "implicit" + type: "oauth2" + x-google-issuer: "https://securetoken.google.com/pi-ostelco-dev" + x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com" + x-google-audiences: "pi-ostelco-dev" + \ No newline at end of file diff --git a/prime/infra/dev/prime-houston-api.yaml b/prime/infra/dev/prime-houston-api.yaml new file mode 100644 index 000000000..dead1d1e6 --- /dev/null +++ b/prime/infra/dev/prime-houston-api.yaml @@ -0,0 +1,526 @@ +swagger: "2.0" +info: + title: "Houston Admin API" + description: "The APIs for the Houston Admin Client." + version: "1.0.0" +host: "houston-api.dev.oya.world" +x-google-endpoints: + - name: "houston-api.dev.oya.world" + allowCors: true +schemes: + - "https" +paths: + "/profiles/{id}": + get: + description: "Get profile for the given email-id or msisdn (url encoded)." + produces: + - application/json + operationId: "getCustomer" + responses: + 200: + description: "Get the profile for this user." + schema: + $ref: '#/definitions/Profile' + 404: + description: "Profile not found." + security: + - auth0_jwt: [] + parameters: + - name: id + in: path + description: "The id of the user (msisdn or email)" + required: true + type: string + "/profiles/{email}/subscriptions": + get: + description: "Get subscription (msisdn) for the user." + produces: + - application/json + operationId: "getSubscriptions" + responses: + 200: + description: "Get subscriptions for this user." + schema: + $ref: '#/definitions/SubscriptionList' + 404: + description: "No subscription found for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/profiles/{email}/scans": + get: + description: "Get eKYC scan information for the user." + produces: + - application/json + operationId: "getAllScanInformation" + responses: + 200: + description: "Retrieved scan information for this user." + schema: + $ref: '#/definitions/ScanInformationList' + 404: + description: "No scan information found for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/profiles/{email}/plans": + get: + description: "Get all plans subscribed to by a customer" + produces: + - application/json + operationId: "getPlans" + security: + - auth0_jwt: [] + responses: + 200: + description: "Plans subscribed to" + schema: + $ref: '#/definitions/PlanList' + 404: + description: "No plans found" + parameters: + - name: email + in: path + description: "The email of the customer" + required: true + type: string + "/profiles/{email}/plans/{planId}": + post: + description: "Subscribe a customer to a plan" + produces: + - application/json + - text/plain + operationId: "attachPlan" + responses: + 201: + description: "The subscription was created successfully" + 400: + description: "Failed to create subscription" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the customer" + required: true + type: string + - name: planId + in: path + description: "The name of the plan to subscribe to" + required: true + type: string + delete: + description: "Remove a customer from a plan" + produces: + - application/json + - text/plain + operationId: "detachPlan" + responses: + 200: + description: "The subscription was removed successfully" + 400: + description: "Failed to remove subscription" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the customer" + required: true + type: string + - name: planId + in: path + description: "The name of the plan to remove the subscription for" + required: true + type: string + "/bundles/{email}": + get: + description: "Get bundles (balance) for the user (identified by email)." + produces: + - application/json + operationId: "getBundlesByEmail" + responses: + 200: + description: "Get bundles for this user." + schema: + $ref: '#/definitions/BundleList' + 404: + description: "No bundle found for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/purchases/{email}": + get: + description: "Get list of all purchases." + produces: + - application/json + - text/plain + operationId: "getPurchaseHistoryByEmail" + responses: + 200: + description: "List of Purchase Records." + schema: + $ref: '#/definitions/PurchaseRecordList' + 403: + description: "Not allowed to charge this source" + 404: + description: "No Purchase Records found for the user." + 503: + description: "Service Unavailable" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/refund/{email}": + put: + description: "Full refund of a purchase." + produces: + - application/json + - text/plain + operationId: "refundPurchaseByEmail" + responses: + 200: + description: "Purchase is refunded." + schema: + type: object + 403: + description: "Forbidden to refund this Purchase" + 404: + description: "Purchase record not found" + 502: + description: "Failed to refund purchase" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + - name: purchaseRecordId + in: query + description: "The record id of the purchase to be refunded" + required: true + type: string + - name: reason + in: query + description: "The reason for refund" + required: true + type: string + "/notify/{email}": + put: + description: "Send notification to a customer." + produces: + - application/json + - text/plain + operationId: "sendNotificationByEmail" + responses: + 200: + description: "Sent notification." + 404: + description: "Subscriber record not found" + 502: + description: "Failed to send notification" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + - name: title + in: query + description: "The title for the notification" + required: true + type: string + - name: message + in: query + description: "The notification message" + required: true + type: string + "/plans": + get: + description: "Get plan details" + produces: + - application/json + - text/plain + operationId: "getPlan" + responses: + 200: + description: "Plan details is returned" + schema: + $ref: '#/definitions/Plan' + 404: + description: "No such plan" + parameters: + - name: planId + in: path + description: "Name of plan to get" + required: true + type: string + security: + - auth0_jwt: [] + post: + description: "Create a plan" + produces: + - application/json + - text/plain + operationId: "createPlan" + responses: + 201: + description: "Successfully purchased the plan." + schema: + $ref: '#/definitions/Plan' + 400: + description: "Failed to create the plan" + parameters: + - name: plan + in: body + description: Plan details + schema: + $ref: '#/definitions/Plan' + security: + - auth0_jwt: [] + delete: + description: "Removes a plan" + produces: + - application/json + - text/plain + operationId: "deletePlan" + responses: + 200: + description: "Plan is removed" + schema: + $ref: '#/definitions/Plan' + 400: + description: "Failed to remove plan" + 404: + description: "No such plan" + parameters: + - name: planId + in: path + description: "The name of the plan to remove" + required: true + type: string + security: + - auth0_jwt: [] + "/profiles/{email}/state": + get: + description: "Get state of the user." + produces: + - application/json + operationId: "getCustomerState" + responses: + 200: + description: "Successfully retrieved the state." + schema: + $ref: '#/definitions/SubscriberState' + 404: + description: "No state information available for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string +definitions: + Profile: + type: object + properties: + name: + type: string + address: + type: string + postCode: + type: string + city: + type: string + country: + type: string + email: + type: string + format: email + referralId: + type: string + required: + - email + SubscriptionList: + type: array + items: + $ref: '#/definitions/Subscription' + Subscription: + type: object + properties: + msisdn: + description: "Mobile number for this subscription" + type: string + alias: + description: "Human readable optional alias for this subscription" + type: string + BundleList: + type: array + items: + $ref: '#/definitions/Bundle' + Bundle: + type: object + properties: + id: + description: "Bundle ID" + type: string + balance: + description: "Balance units in this bundle" + type: integer + format: int64 + PurchaseRecordList: + type: array + items: + $ref: '#/definitions/PurchaseRecord' + PurchaseRecord: + type: object + properties: + id: + description: "Purchase Record ID" + type: string + msisdn: + description: "Deprecated: The MSISDN for which the purchase was made." + type: string + timestamp: + description: "The time stamp of the purchase" + type: integer + format: int64 + product: + $ref: '#/definitions/Product' + required: + - timestamp + - product + - end + Product: + type: object + properties: + sku: + description: "A unique Id representing a SKU" + type: string + price: + $ref: '#/definitions/Price' + properties: + type: object + presentation: + type: object + required: + - sku + - price + Price: + type: object + properties: + amount: + description: "A positive integer in the smallest currency unit" + type: integer + minimum: 0 + currency: + description: "ISO 4217 currency code (three letter alphabetic code)" + type: string + required: + - amount + - currency + Plan: + type: object + properties: + name: + description: "An unique name representing the plan" + type: string + price: + $ref: '#/definitions/Price' + interval: + description: "The recurring period for the plan" + type: string + enum: [ day, week, month, year ] + intervalCount: + description: "Number of intervals in a period" + type: integer + default: 1 + minimum: 1 + properties: + description: "Free form key/value pairs" + type: object + additionalProperties: true + presentation: + description: "Pretty print version of plan" + type: object + additionalProperties: true + required: + - name + - price + - interval + PlanList: + type: array + items: + $ref: '#/definitions/Plan' + SubscriberState: + type: object + properties: + id: + description: "User Id" + type: string + status: + description: "Current status of the customer" + type: string + modifiedTimestamp: + description: "Last modified time for the status (Unix timestamp)" + type: integer + format: int64 + required: + - id + - status + - modifiedTimestamp + ScanInformationList: + type: array + items: + $ref: '#/definitions/ScanInformation' + ScanInformation: + type: object + properties: + scanId: + description: "New scan Id for eKYC" + type: string + countryCode: + description: "The 3 letter country code (or global) for the scan " + type: string + status: + description: "The status of the scan" + type: string + scanResult: + description: "The result from the vendor" + type: object + required: + - scanId + - status +securityDefinitions: + auth0_jwt: + authorizationUrl: "https://redotter-admin-dev.eu.auth0.com/authorize" + flow: "implicit" + type: "oauth2" + x-google-issuer: "https://redotter-admin-dev.eu.auth0.com/" + x-google-jwks_uri: "https://redotter-admin-dev.eu.auth0.com/.well-known/jwks.json" + x-google-audiences: "http://google_api" diff --git a/prime/infra/dev/prime-webhooks.yaml b/prime/infra/dev/prime-webhooks.yaml new file mode 100644 index 000000000..7434125d5 --- /dev/null +++ b/prime/infra/dev/prime-webhooks.yaml @@ -0,0 +1,48 @@ +swagger: "2.0" +info: + title: "Prime 3rd party API" + description: "Prime endpoints for use by external services." + version: "1.0.0" +host: "alvin-api.dev.oya.world" +x-google-endpoints: + - name: "alvin-api.dev.oya.world" + allowCors: true +schemes: + - "https" +paths: + "/stripe/event": + post: + description: "Endpoint for event reports from Stripe." + produces: + - application/json + operationId: "handleEvent" + responses: + 200: + description: "Event report processed successfully." + 400: + description: "Failed to process event report." + 500: + description: "Unexpected error." + security: + - api_key: [] + "/ekyc/callback": + post: + description: "Endpoint for event reports from eKYC." + produces: + - application/json + operationId: "handleCallback" + responses: + 200: + description: "Event report processed successfully." + 400: + description: "Failed to process event report." + 500: + description: "Unexpected error." + security: + - api_key: [] +securityDefinitions: + # This section configures basic authentication with an API key. + api_key: + type: "apiKey" + name: "key" + in: "query" diff --git a/prime/infra/dev/prime.yaml b/prime/infra/dev/prime.yaml deleted file mode 100644 index 7673d030c..000000000 --- a/prime/infra/dev/prime.yaml +++ /dev/null @@ -1,205 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: prime-service - labels: - app: prime - tier: backend -spec: - type: LoadBalancer - loadBalancerIP: 35.190.197.222 - ports: - - name: grpc - port: 443 - targetPort: 8443 - protocol: TCP - selector: - app: prime - tier: backend ---- -apiVersion: v1 -kind: Service -metadata: - name: prime-api - labels: - app: prime - tier: backend -spec: - type: LoadBalancer - loadBalancerIP: 35.190.192.27 - ports: - - name: https - port: 443 - protocol: TCP - selector: - app: prime - tier: backend ---- -apiVersion: v1 -kind: Service -metadata: - name: prime-metrics - labels: - app: prime - tier: backend -spec: - type: LoadBalancer - loadBalancerIP: 146.148.16.201 - ports: - - name: grpc - port: 443 - targetPort: 9443 - protocol: TCP - selector: - app: prime - tier: backend ---- -apiVersion: v1 -kind: Service -metadata: - name: pseudonym-server-service - labels: - app: prime - tier: backend -spec: - ports: - - protocol: TCP - port: 80 - targetPort: 8080 - selector: - app: prime - tier: backend ---- -apiVersion: extensions/v1beta1 -kind: Deployment -metadata: - name: prime - labels: - app: prime - tier: backend -spec: - replicas: 1 - template: - metadata: - labels: - app: prime - tier: backend - annotations: - prometheus.io/scrape: 'true' - prometheus.io/path: '/prometheus-metrics' - prometheus.io/port: '8081' - spec: - initContainers: - - name: "init-downloader" - image: "google/cloud-sdk:latest" - command: ['sh', '-c', 'gsutil cp gs://prime-files/dev/*.* /config-data/'] - volumeMounts: - - name: config-data - mountPath: /config-data/ - containers: - - name: ocs-esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - args: [ - "--http2_port=9000", - "--ssl_port=8443", - "--status_port=8090", - "--service=ocs.dev.ostelco.org", - "--rollout_strategy=managed", - "--backend=grpc://127.0.0.1:8082" - ] - ports: - - containerPort: 9000 - - containerPort: 8443 - volumeMounts: - - mountPath: /etc/nginx/ssl - name: ocs-ostelco-ssl - readOnly: true - - name: api-esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - args: [ - "--http2_port=9002", - "--ssl_port", "443", - "--status_port=8092", - "--service=api.dev.ostelco.org", - "--rollout_strategy=managed", - "--backend=127.0.0.1:8080" - ] - ports: - - containerPort: 9002 - - containerPort: 443 - volumeMounts: - - mountPath: /etc/nginx/ssl - name: api-ostelco-ssl - readOnly: true - - name: metrics-esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - args: [ - "--http2_port=9004", - "--ssl_port=9443", - "--status_port=8094", - "--service=metrics.dev.ostelco.org", - "--rollout_strategy=managed", - "--backend=grpc://127.0.0.1:8083" - ] - ports: - - containerPort: 9004 - - containerPort: 9443 - volumeMounts: - - mountPath: /etc/nginx/ssl - name: metrics-ostelco-ssl - readOnly: true - - name: prime - image: eu.gcr.io/pantel-2decb/prime:PRIME_VERSION - imagePullPolicy: Always - env: - - name: SLACK_CHANNEL - value: prime-alerts - - name: SLACK_WEBHOOK_URI - valueFrom: - secretKeyRef: - name: slack-secrets - key: slackWebHookUri - - name: NEO4J_HOST - value: neo4j - - name: DATASTORE_NAMESPACE - value: dev - - name: FIREBASE_ROOT_PATH - value: dev - - name: DATA_TRAFFIC_TOPIC - value: data-traffic-dev - - name: PURCHASE_INFO_TOPIC - value: purchase-info-dev - - name: ACTIVE_USERS_TOPIC - value: active-users-dev - - name: STRIPE_API_KEY - valueFrom: - secretKeyRef: - name: stripe-secrets - key: stripeApiKey - volumeMounts: - - name: secret-config - mountPath: "/secret" - readOnly: true - - name: config-data - mountPath: "/config-data" - readOnly: true - ports: - - containerPort: 8080 - - containerPort: 8081 - - containerPort: 8082 - - containerPort: 8083 - volumes: - - name: secret-config - secret: - secretName: pantel-prod.json - - name: api-ostelco-ssl - secret: - secretName: api-ostelco-ssl - - name: ocs-ostelco-ssl - secret: - secretName: ocs-ostelco-ssl - - name: metrics-ostelco-ssl - secret: - secretName: metrics-ostelco-ssl - - name: config-data - emptyDir: {} diff --git a/prime/infra/grafana-dashboard.json b/prime/infra/grafana-dashboard.json index aa9d6e4c0..f1c31c904 100644 --- a/prime/infra/grafana-dashboard.json +++ b/prime/infra/grafana-dashboard.json @@ -11,7 +11,7 @@ "rows": [ { "collapse": false, - "height": "250px", + "height": 204, "panels": [ { "cacheTimeout": null, @@ -59,7 +59,7 @@ "to": "null" } ], - "span": 3, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -134,7 +134,7 @@ "to": "null" } ], - "span": 3, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -211,7 +211,7 @@ "to": "null" } ], - "span": 3, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -229,7 +229,7 @@ } ], "thresholds": "", - "title": "Users acquired through referrals", + "title": "Users Acquired Through Referrals", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -240,7 +240,19 @@ } ], "valueName": "current" - }, + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Live Metrics", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 210, + "panels": [ { "cacheTimeout": null, "colorBackground": false, @@ -250,7 +262,7 @@ "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)" ], - "datasource": "prometheus", + "datasource": null, "format": "none", "gauge": { "maxValue": 100, @@ -259,7 +271,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 4, + "id": 12, "interval": null, "links": [], "mappingType": 1, @@ -276,7 +288,7 @@ "maxDataPoints": 100, "nullPointMode": "connected", "nullText": null, - "postfix": "", + "postfix": " NOK ", "postfixFontSize": "50%", "prefix": "", "prefixFontSize": "50%", @@ -287,7 +299,7 @@ "to": "null" } ], - "span": 3, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -296,16 +308,16 @@ }, "targets": [ { - "expr": "scalar(users_paid_at_least_once)", + "expr": "revenue_today / 100", "intervalFactor": 2, "legendFormat": "", - "metric": "users_paid_at_least_once", + "metric": "revenue_today", "refId": "A", "step": 600 } ], "thresholds": "", - "title": "Users paid at least once", + "title": "Revenue Today", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -316,26 +328,14 @@ } ], "valueName": "current" - } - ], - "repeat": null, - "repeatIteration": null, - "repeatRowId": null, - "showTitle": false, - "title": "Dashboard Row", - "titleSize": "h6" - }, - { - "collapse": false, - "height": 274, - "panels": [ + }, { "cacheTimeout": null, "colorBackground": false, "colorValue": false, "colors": [ "rgba(245, 54, 54, 0.9)", - "rgba(208, 134, 42, 0.94)", + "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)" ], "datasource": null, @@ -347,7 +347,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 8, + "id": 14, "interval": null, "links": [], "mappingType": 1, @@ -364,7 +364,7 @@ "maxDataPoints": 100, "nullPointMode": "connected", "nullText": null, - "postfix": "", + "postfix": " NOK ", "postfixFontSize": "50%", "prefix": "", "prefixFontSize": "50%", @@ -375,7 +375,7 @@ "to": "null" } ], - "span": 2, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -384,18 +384,17 @@ }, "targets": [ { - "expr": "sims_who_have_used_data_today", + "expr": "((revenue_today / 100) / scalar(total_users))", "hide": false, "intervalFactor": 2, - "metric": "sims_who_have_used_data_today", + "legendFormat": "", + "metric": "total_users", "refId": "A", "step": 600 } ], "thresholds": "", - "timeFrom": null, - "timeShift": null, - "title": "Simcards using data today", + "title": "ARPU Today", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -417,7 +416,6 @@ "rgba(50, 172, 45, 0.97)" ], "datasource": null, - "decimals": null, "format": "none", "gauge": { "maxValue": 100, @@ -426,7 +424,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 10, + "id": 16, "interval": null, "links": [], "mappingType": 1, @@ -443,7 +441,7 @@ "maxDataPoints": 100, "nullPointMode": "connected", "nullText": null, - "postfix": " MB", + "postfix": "", "postfixFontSize": "50%", "prefix": "", "prefixFontSize": "50%", @@ -454,7 +452,7 @@ "to": "null" } ], - "span": 2, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -463,16 +461,15 @@ }, "targets": [ { - "expr": "total_data_used_today / 1000000", + "expr": "total_paid_users_today", "intervalFactor": 2, - "legendFormat": "", - "metric": "total_data_used_today", + "metric": "total_paid_users_today", "refId": "A", "step": 600 } ], "thresholds": "", - "title": "Total data usage today", + "title": "Users Who Paid Today", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -485,80 +482,84 @@ "valueName": "current" }, { - "cacheTimeout": null, - "colorBackground": false, - "colorValue": false, - "colors": [ - "rgba(245, 54, 54, 0.9)", - "rgba(237, 129, 40, 0.89)", - "rgba(50, 172, 45, 0.97)" - ], - "datasource": null, - "format": "none", - "gauge": { - "maxValue": 100, - "minValue": 0, + "aliasColors": {}, + "bars": false, + "datasource": "prometheus", + "fill": 1, + "id": 18, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, "show": false, - "thresholdLabels": false, - "thresholdMarkers": true + "total": false, + "values": false }, - "id": 12, - "interval": null, + "lines": true, + "linewidth": 1, "links": [], - "mappingType": 1, - "mappingTypes": [ + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 8, + "stack": false, + "steppedLine": false, + "targets": [ { - "name": "value to text", - "value": 1 + "expr": "total_data_used_today_local_loltel_test", + "intervalFactor": 2, + "legendFormat": "Norway", + "metric": "total_data_used_today_local_loltel_test", + "refId": "A", + "step": 40 }, { - "name": "range to text", - "value": 2 - } - ], - "maxDataPoints": 100, - "nullPointMode": "connected", - "nullText": null, - "postfix": "", - "postfixFontSize": "50%", - "prefix": "NOK ", - "prefixFontSize": "50%", - "rangeMaps": [ - { - "from": "null", - "text": "N/A", - "to": "null" + "expr": "total_data_used_today_roaming_loltel_test", + "intervalFactor": 2, + "legendFormat": "Roaming", + "metric": "total_data_used_today_roaming_loltel_test", + "refId": "B", + "step": 40 } ], - "span": 2, - "sparkline": { - "fillColor": "rgba(31, 118, 189, 0.18)", - "full": false, - "lineColor": "rgb(31, 120, 193)", - "show": false + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Data Usage Today", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" }, - "targets": [ + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ { - "expr": "revenue_today / 100", - "intervalFactor": 2, - "legendFormat": "", - "metric": "revenue_today", - "refId": "A", - "step": 600 - } - ], - "thresholds": "", - "title": "Revenue today", - "type": "singlestat", - "valueFontSize": "80%", - "valueMaps": [ + "format": "decbytes", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, { - "op": "=", - "text": "N/A", - "value": "null" + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true } - ], - "valueName": "current" + ] }, { "cacheTimeout": null, @@ -570,7 +571,8 @@ "rgba(50, 172, 45, 0.97)" ], "datasource": null, - "format": "none", + "decimals": null, + "format": "decmbytes", "gauge": { "maxValue": 100, "minValue": 0, @@ -578,7 +580,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 14, + "id": 10, "interval": null, "links": [], "mappingType": 1, @@ -597,7 +599,7 @@ "nullText": null, "postfix": "", "postfixFontSize": "50%", - "prefix": "NOK ", + "prefix": "", "prefixFontSize": "50%", "rangeMaps": [ { @@ -615,17 +617,16 @@ }, "targets": [ { - "expr": "((revenue_today / 100) / scalar(total_users))", - "hide": false, + "expr": "total_data_used_today / 1000000", "intervalFactor": 2, "legendFormat": "", - "metric": "total_users", + "metric": "total_data_used_today", "refId": "A", "step": 600 } ], "thresholds": "", - "title": "ARPU today", + "title": "Total Data Used Today", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -647,7 +648,8 @@ "rgba(50, 172, 45, 0.97)" ], "datasource": null, - "format": "none", + "decimals": null, + "format": "decmbytes", "gauge": { "maxValue": 100, "minValue": 0, @@ -655,7 +657,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 16, + "id": 20, "interval": null, "links": [], "mappingType": 1, @@ -692,15 +694,16 @@ }, "targets": [ { - "expr": "total_paid_users_today", + "expr": "total_data_used_today_roaming_loltel_test / 1000000", "intervalFactor": 2, - "metric": "total_paid_users_today", + "legendFormat": "", + "metric": "total_data_used_today_roaming_loltel_test", "refId": "A", "step": 600 } ], "thresholds": "", - "title": "Paid users today", + "title": "Total Data Used Today Roaming", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -715,9 +718,9 @@ { "aliasColors": {}, "bars": false, - "datasource": "prometheus", + "datasource": null, "fill": 1, - "id": 18, + "id": 24, "legend": { "avg": false, "current": false, @@ -736,32 +739,47 @@ "points": false, "renderer": "flot", "seriesOverrides": [], - "span": 6, + "span": 8, "stack": false, "steppedLine": false, "targets": [ { - "expr": "total_data_used_today_local_loltel_test", + "expr": "active_app_users_today", "intervalFactor": 2, - "legendFormat": "", - "metric": "total_data_used_today_local_loltel_test", + "legendFormat": "Active App Users", + "metric": "active_app_users_today", "refId": "A", - "step": 60 + "step": 40 }, { - "expr": "total_data_used_today_roaming_loltel_test", + "expr": "sims_who_have_been_active_today", "intervalFactor": 2, - "metric": "total_data_used_today_roaming_loltel_test", + "legendFormat": "Active SIMs", "refId": "B", - "step": 60 + "step": 40 + }, + { + "expr": "sims_who_have_used_data_today", + "intervalFactor": 2, + "legendFormat": "Sims That Has Used Data", + "refId": "C", + "step": 40 + }, + { + "expr": "sims_who_have_been_active_today_roaming", + "intervalFactor": 2, + "legendFormat": "Active SIMs Roaming", + "metric": "sims_who_have_been_active_today_roaming", + "refId": "D", + "step": 40 } ], "thresholds": [], "timeFrom": null, "timeShift": null, - "title": "Data Usage Today", + "title": "Active Users Today", "tooltip": { - "shared": false, + "shared": true, "sort": 0, "value_type": "individual" }, @@ -774,8 +792,8 @@ }, "yaxes": [ { - "format": "decbytes", - "label": "Bytes", + "format": "short", + "label": null, "logBase": 1, "max": null, "min": null, @@ -783,7 +801,7 @@ }, { "format": "short", - "label": "", + "label": null, "logBase": 1, "max": null, "min": null, @@ -801,8 +819,7 @@ "rgba(50, 172, 45, 0.97)" ], "datasource": null, - "decimals": null, - "format": "decmbytes", + "format": "none", "gauge": { "maxValue": 100, "minValue": 0, @@ -810,7 +827,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 20, + "id": 22, "interval": null, "links": [], "mappingType": 1, @@ -838,7 +855,7 @@ "to": "null" } ], - "span": 2, + "span": 1, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -847,16 +864,15 @@ }, "targets": [ { - "expr": "total_data_used_today_roaming_loltel_test / 1000000", + "expr": "sims_who_have_been_active_today", "intervalFactor": 2, - "legendFormat": "", - "metric": "total_data_used_today_roaming_loltel_test", + "metric": "sims_who_have_been_active_today", "refId": "A", "step": 600 } ], "thresholds": "", - "title": "Total Data Used Today Roaming", + "title": "Active Sims Today", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -866,26 +882,14 @@ "value": "null" } ], - "valueName": "avg" - } - ], - "repeat": null, - "repeatIteration": null, - "repeatRowId": null, - "showTitle": true, - "title": "So far Today", - "titleSize": "h6" - }, - { - "collapse": false, - "height": 242, - "panels": [ + "valueName": "current" + }, { "cacheTimeout": null, - "colorBackground": true, + "colorBackground": false, "colorValue": false, "colors": [ - "rgba(255, 0, 0, 0.9)", + "rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)" ], @@ -898,7 +902,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 9, + "id": 26, "interval": null, "links": [], "mappingType": 1, @@ -935,16 +939,15 @@ }, "targets": [ { - "expr": "sims_who_have_used_data_yesterday", - "hide": false, + "expr": "sims_who_have_been_active_today_roaming", "intervalFactor": 2, - "metric": "sims_who_have_used_data_yesterday", + "metric": "sims_who_have_been_active_today", "refId": "A", "step": 600 } ], "thresholds": "", - "title": "Sims used data yesterday", + "title": "Active Sims Today Roaming", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -958,11 +961,11 @@ }, { "cacheTimeout": null, - "colorBackground": true, + "colorBackground": false, "colorValue": false, "colors": [ "rgba(245, 54, 54, 0.9)", - "rgba(237, 129, 40, 0.89)", + "rgba(208, 134, 42, 0.94)", "rgba(50, 172, 45, 0.97)" ], "datasource": null, @@ -974,7 +977,7 @@ "thresholdLabels": false, "thresholdMarkers": true }, - "id": 11, + "id": 8, "interval": null, "links": [], "mappingType": 1, @@ -991,7 +994,7 @@ "maxDataPoints": 100, "nullPointMode": "connected", "nullText": null, - "postfix": " MB", + "postfix": "", "postfixFontSize": "50%", "prefix": "", "prefixFontSize": "50%", @@ -1002,7 +1005,7 @@ "to": "null" } ], - "span": 2, + "span": 1, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -1011,16 +1014,18 @@ }, "targets": [ { - "expr": "total_data_used_yesterday / 1000000", + "expr": "sims_who_have_used_data_today", + "hide": false, "intervalFactor": 2, - "legendFormat": "", - "metric": "total_data_used_yesterday", + "metric": "sims_who_have_used_data_today", "refId": "A", "step": 600 } ], "thresholds": "", - "title": "Total data usage yesterday", + "timeFrom": null, + "timeShift": null, + "title": "Simcards Using Data Today", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -1031,7 +1036,19 @@ } ], "valueName": "current" - }, + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "So far Today", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 166, + "panels": [ { "cacheTimeout": null, "colorBackground": true, @@ -1067,9 +1084,9 @@ "maxDataPoints": 100, "nullPointMode": "connected", "nullText": null, - "postfix": "", + "postfix": " NOK ", "postfixFontSize": "50%", - "prefix": "NOK ", + "prefix": "", "prefixFontSize": "50%", "rangeMaps": [ { @@ -1078,7 +1095,7 @@ "to": "null" } ], - "span": 2, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -1095,8 +1112,8 @@ "step": 600 } ], - "thresholds": "", - "title": "Revenue yesterday", + "thresholds": "0,", + "title": "Revenue Yesterday", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -1143,9 +1160,9 @@ "maxDataPoints": 100, "nullPointMode": "connected", "nullText": null, - "postfix": "", + "postfix": " NOK ", "postfixFontSize": "50%", - "prefix": "NOK ", + "prefix": "", "prefixFontSize": "50%", "rangeMaps": [ { @@ -1154,7 +1171,7 @@ "to": "null" } ], - "span": 2, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -1171,8 +1188,8 @@ "step": 600 } ], - "thresholds": "", - "title": "ARPU yesterday", + "thresholds": "0,", + "title": "ARPU Yesterday", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -1230,7 +1247,7 @@ "to": "null" } ], - "span": 2, + "span": 4, "sparkline": { "fillColor": "rgba(31, 118, 189, 0.18)", "full": false, @@ -1246,8 +1263,8 @@ "step": 600 } ], - "thresholds": "", - "title": "Paid users yesterday", + "thresholds": "0,", + "title": "Users Who Paid Yesterday", "type": "singlestat", "valueFontSize": "80%", "valueMaps": [ @@ -1283,25 +1300,25 @@ "points": false, "renderer": "flot", "seriesOverrides": [], - "span": 6, + "span": 8, "stack": false, "steppedLine": false, "targets": [ { - "expr": "total_data_used_yesterday", + "expr": "total_data_used_yesterday_local_lotlel_test", "intervalFactor": 2, - "legendFormat": "", - "metric": "total_data_used_yesterday", + "legendFormat": "Norway", + "metric": "total_data_used_yesterday_local_lotlel_test", "refId": "A", - "step": 60 + "step": 40 }, { "expr": "total_data_used_yesterday_roaming_lotlel_test", "intervalFactor": 2, - "legendFormat": "", + "legendFormat": "Roaming", "metric": "total_data_used_yesterday_roaming_lotlel_test", "refId": "B", - "step": 60 + "step": 40 } ], "thresholds": [], @@ -1323,7 +1340,7 @@ "yaxes": [ { "format": "decbytes", - "label": "Bytes", + "label": "", "logBase": 1, "max": null, "min": null, @@ -1331,7 +1348,7 @@ }, { "format": "short", - "label": null, + "label": "", "logBase": 1, "max": null, "min": null, @@ -1341,7 +1358,83 @@ }, { "cacheTimeout": null, - "colorBackground": false, + "colorBackground": true, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": null, + "format": "decmbytes", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 11, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 2, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "total_data_used_yesterday / 1000000", + "intervalFactor": 2, + "legendFormat": "", + "metric": "total_data_used_yesterday", + "refId": "A", + "step": 600 + } + ], + "thresholds": "0,", + "title": "Total Data Used Yesterday", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": true, "colorValue": false, "colors": [ "rgba(245, 54, 54, 0.9)", @@ -1403,7 +1496,7 @@ "step": 600 } ], - "thresholds": "", + "thresholds": "0,", "title": "Total Data Used Yesterday Roaming", "type": "singlestat", "valueFontSize": "80%", @@ -1414,7 +1507,331 @@ "value": "null" } ], - "valueName": "avg" + "valueName": "current" + }, + { + "aliasColors": {}, + "bars": false, + "datasource": null, + "fill": 1, + "id": 25, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 8, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "active_app_users_yesterday", + "intervalFactor": 2, + "legendFormat": "Active App Users", + "metric": "active_app_users_today", + "refId": "A", + "step": 40 + }, + { + "expr": "sims_who_was_active_yesterday", + "intervalFactor": 2, + "legendFormat": "Active SIMs", + "metric": "sims_who_was_active_yesterday", + "refId": "B", + "step": 40 + }, + { + "expr": "sims_who_have_used_data_yesterday", + "intervalFactor": 2, + "legendFormat": "Sims That Has Used Data", + "metric": "sims_who_have_used_data_yesterday", + "refId": "C", + "step": 40 + }, + { + "expr": "sims_who_was_active_yesterday_roaming", + "intervalFactor": 2, + "legendFormat": "Active SIMs Roaming", + "metric": "sims_who_was_active_yesterday_roaming", + "refId": "D", + "step": 40 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Active Users Yesterday", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "cacheTimeout": null, + "colorBackground": true, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": null, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 23, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 1, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sims_who_was_active_yesterday", + "intervalFactor": 2, + "metric": "sims_who_was_active_yesterday", + "refId": "A", + "step": 600 + } + ], + "thresholds": "0,", + "title": "SIMs Active Yesterday", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": true, + "colorValue": false, + "colors": [ + "rgba(255, 0, 0, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": null, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 9, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 2, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sims_who_was_active_yesterday_roaming", + "hide": false, + "intervalFactor": 2, + "legendFormat": "", + "metric": "sims_who_was_active_yesterday_roaming", + "refId": "A", + "step": 600 + } + ], + "thresholds": "0,", + "title": "Active Sims Yesterday Roaming", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": true, + "colorValue": false, + "colors": [ + "rgba(255, 0, 0, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": null, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 27, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 1, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sims_who_have_used_data_yesterday", + "hide": false, + "intervalFactor": 2, + "metric": "sims_who_have_used_data_yesterday", + "refId": "A", + "step": 600 + } + ], + "thresholds": "0,", + "title": "Sims Used Data Yesterday", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" } ], "repeat": null, @@ -1423,17 +1840,6 @@ "showTitle": true, "title": "Yesterday", "titleSize": "h6" - }, - { - "collapse": false, - "height": 250, - "panels": [], - "repeat": null, - "repeatIteration": null, - "repeatRowId": null, - "showTitle": false, - "title": "Dashboard Row", - "titleSize": "h6" } ], "schemaVersion": 14, @@ -1443,7 +1849,7 @@ "list": [] }, "time": { - "from": "now-6h", + "from": "now-12h", "to": "now" }, "timepicker": { @@ -1473,5 +1879,5 @@ }, "timezone": "browser", "title": "Dashboard", - "version": 27 + "version": 58 } \ No newline at end of file diff --git a/prime/infra/new-dev/metrics-api.yaml b/prime/infra/new-dev/metrics-api.yaml deleted file mode 100644 index 109346180..000000000 --- a/prime/infra/new-dev/metrics-api.yaml +++ /dev/null @@ -1,30 +0,0 @@ -type: google.api.Service - -config_version: 3 - -name: metrics.new.dev.ostelco.org - -title: Prime Metrics Reporter Service gRPC API - -apis: - - name: org.ostelco.prime.metrics.api.OcsgwAnalyticsService - -usage: - rules: - # All methods can be called without an API Key. - - selector: "*" - allow_unregistered_calls: true - -authentication: - providers: - - id: google_service_account - issuer: esp-credentials@pi-ostelco-dev.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/esp-credentials@pi-ostelco-dev.iam.gserviceaccount.com - audiences: > - https://metrics.new.dev.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - metrics.new.dev.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - metrics.new.dev.ostelco.org - rules: - - selector: "*" - requirements: - - provider_id: google_service_account \ No newline at end of file diff --git a/prime/infra/new-dev/ocs-api.yaml b/prime/infra/new-dev/ocs-api.yaml deleted file mode 100644 index a8a9b8865..000000000 --- a/prime/infra/new-dev/ocs-api.yaml +++ /dev/null @@ -1,30 +0,0 @@ -type: google.api.Service - -config_version: 3 - -name: ocs.new.dev.ostelco.org - -title: OCS Service gRPC API - -apis: - - name: org.ostelco.ocs.api.OcsService - -usage: - rules: - # All methods can be called without an API Key. - - selector: "*" - allow_unregistered_calls: true - -authentication: - providers: - - id: google_service_account - issuer: esp-credentials@pi-ostelco-dev.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/esp-credentials@pi-ostelco-dev.iam.gserviceaccount.com - audiences: > - https://ocs.new.dev.ostelco.org/org.ostelco.ocs.api.OcsService, - ocs.new.dev.ostelco.org/org.ostelco.ocs.api.OcsService, - ocs.new.dev.ostelco.org - rules: - - selector: "*" - requirements: - - provider_id: google_service_account \ No newline at end of file diff --git a/prime/infra/new-dev/prime-client-api.yaml b/prime/infra/new-dev/prime-client-api.yaml deleted file mode 100644 index 7af2407e1..000000000 --- a/prime/infra/new-dev/prime-client-api.yaml +++ /dev/null @@ -1,598 +0,0 @@ -swagger: "2.0" -info: - title: "Ostelco API" - description: "The client API for Panacea." - version: "1.0.0" -host: "api.new.dev.ostelco.org" -x-google-endpoints: - - name: "api.new.dev.ostelco.org" - allowCors: true -schemes: - - "https" -paths: - "/profile": - get: - description: "Get profile for the user (email-id present in the bearer token)." - produces: - - application/json - operationId: "getProfile" - responses: - 200: - description: "Get the profile for this user." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - post: - description: "Create a new profile." - consumes: - - application/json - produces: - - application/json - operationId: "createProfile" - parameters: - - name: profile - in: body - description: The profile to create. - schema: - $ref: '#/definitions/Profile' - - name: referred_by - in: query - description: "Referral ID of user who has invited this user" - type: string - responses: - 201: - description: "Successfully created the profile." - schema: - $ref: '#/definitions/Profile' - security: - - auth0_jwt: [] - put: - description: "Update an existing profile." - consumes: - - application/json - produces: - - application/json - operationId: "updateProfile" - parameters: - - in: body - name: profile - description: The updated profile. - schema: - $ref: '#/definitions/Profile' - responses: - 200: - description: "Successfully updated the profile." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - "/applicationtoken": - post: - description: "Store application token" - consumes: - - application/json - produces: - - application/json - operationId: "storeApplicationToken" - parameters: - - name: applicationToken - in: body - description: application token - schema: - $ref: '#/definitions/ApplicationToken' - responses: - 201: - description: "Successfully stored token." - schema: - $ref: '#/definitions/ApplicationToken' - 404: - description: "User not found." - 507: - description: "Not able to store token." - security: - - auth0_jwt: [] - "/paymentSources": - get: - description: "Get all payment sources for the user." - produces: - - application/json - operationId: "listSources" - responses: - 200: - description: "List of payment sources." - schema: - $ref: '#/definitions/PaymentSourceList' - 404: - description: "No user found." - security: - - auth0_jwt: [] - post: - description: "Add a new payment source for user" - produces: - - application/json - operationId: "createSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be added to user" - required: true - type: string - responses: - 201: - description: "Successfully added source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - put: - description: "Set the source as default for user" - produces: - - application/json - operationId: "setDefaultSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the default source" - required: true - type: string - responses: - 200: - description: "Successfully set as default source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - delete: - description: "Remove a payment source for user" - produces: - - application/json - operationId: "removeSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be removed" - required: true - type: string - responses: - 200: - description: "Successfully removed the source" - schema: - $ref: '#/definitions/PaymentSource' - 400: - description: "The source could not be removed" - 404: - description: "No such source for user" - security: - - auth0_jwt: [] - "/products": - get: - description: "Get all products for the user." - produces: - - application/json - operationId: "getAllProducts" - responses: - 200: - description: "List of products." - schema: - $ref: '#/definitions/ProductList' - 404: - description: "No products found for the user." - security: - - auth0_jwt: [] - "/products/{sku}": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "buyProductDeprecated" - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - parameters: - - name: sku - in: path - description: SKU to be purchased - required: true - type: string - "/products/{sku}/purchase": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "purchaseProduct" - parameters: - - name: sku - in: path - description: "SKU to be purchased" - required: true - type: string - - name: sourceId - in: query - description: "The stripe-id of the source to be used for this purchase (if empty, use default source)" - required: false - type: string - - name: saveCard - in: query - description: "Whether to save this card as a source for this user (default = false)" - required: false - type: boolean - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - "/purchases": - get: - description: "Get list of all purchases." - produces: - - application/json - - text/plain - operationId: "getPurchaseHistory" - responses: - 200: - description: "List of Purchase Records." - schema: - $ref: '#/definitions/PurchaseRecordList' - 404: - description: "No Purchase Records found for the user." - security: - - auth0_jwt: [] - "/subscriptions": - get: - description: "Get subscription (msisdn) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptions" - responses: - 200: - description: "Get subscriptions for this user." - schema: - $ref: '#/definitions/SubscriptionList' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/bundles": - get: - description: "Get bundles (balance) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getBundles" - responses: - 200: - description: "Get bundles for this user." - schema: - $ref: '#/definitions/BundleList' - 404: - description: "No bundle found for this user." - security: - - auth0_jwt: [] - "/subscription/status": - get: - description: "Get subscription status for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptionStatus" - responses: - 200: - description: "Get the subscription status for this user." - schema: - $ref: '#/definitions/SubscriptionStatus' - 404: - description: "No subscription status found for this user." - security: - - auth0_jwt: [] - "/subscription/activePseudonyms": - get: - description: "Get currently active pseudonyms for the user's msisdn (identified by bearer token)." - produces: - - application/json - operationId: "getActivePseudonyms" - responses: - 200: - description: "Get active pseudonyms for the user's msisdn." - schema: - $ref: '#/definitions/ActivePseudonyms' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/referred": - get: - description: "Get list of people whom the user has referred to." - produces: - - application/json - operationId: "getReferred" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/PersonList' - 404: - description: "No referrals found for this user." - security: - - auth0_jwt: [] - "/referred/by": - get: - description: "Get the people who had referred this user." - produces: - - application/json - operationId: "getReferredBy" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/Person' - 404: - description: "No 'referred by' found for this user." - security: - - auth0_jwt: [] - "/consents": - get: - description: "Get all consents for the user." - produces: - - application/json - operationId: "getConsents" - responses: - 200: - description: "List of consents." - schema: - $ref: '#/definitions/ConsentList' - 404: - description: "No consents found for the user." - security: - - auth0_jwt: [] - "/consents/{consent-id}": - put: - description: "Change the value for the specified consent." - operationId: "updateConsent" - responses: - 200: - description: "Successfully updated the consent." - 404: - description: "Consent not found." - security: - - auth0_jwt: [] - parameters: - - name: consent-id - in: path - description: "Id of the consent to be changed" - required: true - type: string - - name: accepted - in: query - description: "Whether user accepted the consent (default = true)" - required: false - type: boolean -definitions: - Profile: - type: object - properties: - name: - type: string - address: - type: string - postCode: - type: string - city: - type: string - country: - type: string - email: - type: string - format: email - referralId: - type: string - required: - - email - SubscriptionList: - type: array - items: - $ref: '#/definitions/Subscription' - Subscription: - type: object - properties: - msisdn: - description: "Mobile number for this subscription" - type: string - BundleList: - type: array - items: - $ref: '#/definitions/Bundle' - Bundle: - type: object - properties: - id: - description: "Bundle ID" - type: string - balance: - description: "Balance units in this bundle" - type: integer - format: int64 - SubscriptionStatus: - type: object - properties: - remaining: - description: "Remaining data" - type: integer - format: int64 - purchaseRecords: - description: "List of Purchases" - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecordList: - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecord: - type: object - properties: - id: - description: "Purchase Record ID" - type: string - msisdn: - description: "Deprecated: The MSISDN for which the purchase was made." - type: string - timestamp: - description: "The time stamp of the purchase" - type: integer - format: int64 - product: - $ref: '#/definitions/Product' - required: - - timestamp - - product - ProductList: - type: array - items: - $ref: '#/definitions/Product' - Product: - type: object - properties: - sku: - description: "A unique Id representing a SKU" - type: string - price: - $ref: '#/definitions/Price' - properties: - type: object - presentation: - type: object - required: - - sku - - price - PaymentSourceList: - type: array - items: - $ref: '#/definitions/PaymentSource' - PaymentSource: - type: object - properties: - id: - description: "The identifier for the source" - type: string - type: - description: "The type of source" - type: string - details: - description: "All information stored with the source" - type: object - additionalProperties: true - required: - - id - - type - ConsentList: - type: array - items: - $ref: '#/definitions/Consent' - Consent: - type: object - properties: - consentId: - description: "The identifier of the consent" - type: string - description: - description: "A description of the consent" - type: string - accepted: - description: "Whether user has accepted the consent or not" - type: boolean - Price: - type: object - properties: - amount: - description: "A positive integer in the smallest currency unit" - type: integer - minimum: 0 - currency: - description: "ISO 4217 currency code (three letter alphabetic code)" - type: string - required: - - amount - - currency - ApplicationToken: - type: object - properties: - token: - description: "Application token" - type: string - applicationID: - description: "Uniquely identifier for the app instance" - type: string - tokenType: - description: "Type of application token (FCM)" - type: string - required: - - token - - applicationID - PseudonymEntity: - type: object - properties: - sourceId: - type: string - pseudonym: - type: string - start: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - end: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - required: - - sourceId - - pseudonym - - start - - end - Person: - type: object - properties: - name: - type: string - required: - - name - PersonList: - type: array - items: - $ref: '#/definitions/Person' - ActivePseudonyms: - type: object - properties: - current: - $ref: '#/definitions/PseudonymEntity' - next: - $ref: '#/definitions/PseudonymEntity' - required: - - current - - next -securityDefinitions: - auth0_jwt: - authorizationUrl: "https://ostelco.eu.auth0.com/authorize" - flow: "implicit" - type: "oauth2" - x-google-issuer: "https://ostelco.eu.auth0.com/" - x-google-jwks_uri: "https://ostelco.eu.auth0.com/.well-known/jwks.json" - x-google-audiences: "http://google_api" diff --git a/prime/infra/new-prod/metrics-api.yaml b/prime/infra/new-prod/metrics-api.yaml deleted file mode 100644 index c8775db24..000000000 --- a/prime/infra/new-prod/metrics-api.yaml +++ /dev/null @@ -1,30 +0,0 @@ -type: google.api.Service - -config_version: 3 - -name: prod-metrics.new.dev.ostelco.org - -title: Prime Metrics Reporter Service gRPC API - -apis: - - name: org.ostelco.prime.metrics.api.OcsgwAnalyticsService - -usage: - rules: - # All methods can be called without an API Key. - - selector: "*" - allow_unregistered_calls: true - -authentication: - providers: - - id: google_service_account - issuer: ci-endpoint-update@pi-ostelco-prod.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/ci-endpoint-update@pi-ostelco-prod.iam.gserviceaccount.com - audiences: > - https://prod-metrics.new.dev.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - prod-metrics.new.dev.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - prod-metrics.new.dev.ostelco.org - rules: - - selector: "*" - requirements: - - provider_id: google_service_account \ No newline at end of file diff --git a/prime/infra/new-prod/ocs-api.yaml b/prime/infra/new-prod/ocs-api.yaml deleted file mode 100644 index 5b2f99f09..000000000 --- a/prime/infra/new-prod/ocs-api.yaml +++ /dev/null @@ -1,30 +0,0 @@ -type: google.api.Service - -config_version: 3 - -name: prod-ocs.new.dev.ostelco.org - -title: OCS Service gRPC API - -apis: - - name: org.ostelco.ocs.api.OcsService - -usage: - rules: - # All methods can be called without an API Key. - - selector: "*" - allow_unregistered_calls: true - -authentication: - providers: - - id: google_service_account - issuer: ci-endpoint-update@pi-ostelco-prod.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/ci-endpoint-update@pi-ostelco-prod.iam.gserviceaccount.com - audiences: > - https://prod-ocs.new.dev.ostelco.org/org.ostelco.ocs.api.OcsService, - prod-ocs.new.dev.ostelco.org/org.ostelco.ocs.api.OcsService, - prod-ocs.new.dev.ostelco.org - rules: - - selector: "*" - requirements: - - provider_id: google_service_account \ No newline at end of file diff --git a/prime/infra/new-prod/prime-client-api.yaml b/prime/infra/new-prod/prime-client-api.yaml deleted file mode 100644 index 09d1d2448..000000000 --- a/prime/infra/new-prod/prime-client-api.yaml +++ /dev/null @@ -1,598 +0,0 @@ -swagger: "2.0" -info: - title: "Ostelco API" - description: "The client API for Panacea." - version: "1.0.0" -host: "prod-api.new.dev.ostelco.org" -x-google-endpoints: - - name: "prod-api.new.dev.ostelco.org" - allowCors: true -schemes: - - "https" -paths: - "/profile": - get: - description: "Get profile for the user (email-id present in the bearer token)." - produces: - - application/json - operationId: "getProfile" - responses: - 200: - description: "Get the profile for this user." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - post: - description: "Create a new profile." - consumes: - - application/json - produces: - - application/json - operationId: "createProfile" - parameters: - - name: profile - in: body - description: The profile to create. - schema: - $ref: '#/definitions/Profile' - - name: referred_by - in: query - description: "Referral ID of user who has invited this user" - type: string - responses: - 201: - description: "Successfully created the profile." - schema: - $ref: '#/definitions/Profile' - security: - - auth0_jwt: [] - put: - description: "Update an existing profile." - consumes: - - application/json - produces: - - application/json - operationId: "updateProfile" - parameters: - - in: body - name: profile - description: The updated profile. - schema: - $ref: '#/definitions/Profile' - responses: - 200: - description: "Successfully updated the profile." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - "/applicationtoken": - post: - description: "Store application token" - consumes: - - application/json - produces: - - application/json - operationId: "storeApplicationToken" - parameters: - - name: applicationToken - in: body - description: application token - schema: - $ref: '#/definitions/ApplicationToken' - responses: - 201: - description: "Successfully stored token." - schema: - $ref: '#/definitions/ApplicationToken' - 404: - description: "User not found." - 507: - description: "Not able to store token." - security: - - auth0_jwt: [] - "/paymentSources": - get: - description: "Get all payment sources for the user." - produces: - - application/json - operationId: "listSources" - responses: - 200: - description: "List of payment sources." - schema: - $ref: '#/definitions/PaymentSourceList' - 404: - description: "No user found." - security: - - auth0_jwt: [] - post: - description: "Add a new payment source for user" - produces: - - application/json - operationId: "createSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be added to user" - required: true - type: string - responses: - 201: - description: "Successfully added source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - put: - description: "Set the source as default for user" - produces: - - application/json - operationId: "setDefaultSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the default source" - required: true - type: string - responses: - 200: - description: "Successfully set as default source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - delete: - description: "Remove a payment source for user" - produces: - - application/json - operationId: "removeSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be removed" - required: true - type: string - responses: - 200: - description: "Successfully removed the source" - schema: - $ref: '#/definitions/PaymentSource' - 400: - description: "The source could not be removed" - 404: - description: "No such source for user" - security: - - auth0_jwt: [] - "/products": - get: - description: "Get all products for the user." - produces: - - application/json - operationId: "getAllProducts" - responses: - 200: - description: "List of products." - schema: - $ref: '#/definitions/ProductList' - 404: - description: "No products found for the user." - security: - - auth0_jwt: [] - "/products/{sku}": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "buyProductDeprecated" - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - parameters: - - name: sku - in: path - description: SKU to be purchased - required: true - type: string - "/products/{sku}/purchase": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "purchaseProduct" - parameters: - - name: sku - in: path - description: "SKU to be purchased" - required: true - type: string - - name: sourceId - in: query - description: "The stripe-id of the source to be used for this purchase (if empty, use default source)" - required: false - type: string - - name: saveCard - in: query - description: "Whether to save this card as a source for this user (default = false)" - required: false - type: boolean - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - "/purchases": - get: - description: "Get list of all purchases." - produces: - - application/json - - text/plain - operationId: "getPurchaseHistory" - responses: - 200: - description: "List of Purchase Records." - schema: - $ref: '#/definitions/PurchaseRecordList' - 404: - description: "No Purchase Records found for the user." - security: - - auth0_jwt: [] - "/subscriptions": - get: - description: "Get subscription (msisdn) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptions" - responses: - 200: - description: "Get subscriptions for this user." - schema: - $ref: '#/definitions/SubscriptionList' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/bundles": - get: - description: "Get bundles (balance) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getBundles" - responses: - 200: - description: "Get bundles for this user." - schema: - $ref: '#/definitions/BundleList' - 404: - description: "No bundle found for this user." - security: - - auth0_jwt: [] - "/subscription/status": - get: - description: "Get subscription status for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptionStatus" - responses: - 200: - description: "Get the subscription status for this user." - schema: - $ref: '#/definitions/SubscriptionStatus' - 404: - description: "No subscription status found for this user." - security: - - auth0_jwt: [] - "/subscription/activePseudonyms": - get: - description: "Get currently active pseudonyms for the user's msisdn (identified by bearer token)." - produces: - - application/json - operationId: "getActivePseudonyms" - responses: - 200: - description: "Get active pseudonyms for the user's msisdn." - schema: - $ref: '#/definitions/ActivePseudonyms' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/referred": - get: - description: "Get list of people whom the user has referred to." - produces: - - application/json - operationId: "getReferred" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/PersonList' - 404: - description: "No referrals found for this user." - security: - - auth0_jwt: [] - "/referred/by": - get: - description: "Get the people who had referred this user." - produces: - - application/json - operationId: "getReferredBy" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/Person' - 404: - description: "No 'referred by' found for this user." - security: - - auth0_jwt: [] - "/consents": - get: - description: "Get all consents for the user." - produces: - - application/json - operationId: "getConsents" - responses: - 200: - description: "List of consents." - schema: - $ref: '#/definitions/ConsentList' - 404: - description: "No consents found for the user." - security: - - auth0_jwt: [] - "/consents/{consent-id}": - put: - description: "Change the value for the specified consent." - operationId: "updateConsent" - responses: - 200: - description: "Successfully updated the consent." - 404: - description: "Consent not found." - security: - - auth0_jwt: [] - parameters: - - name: consent-id - in: path - description: "Id of the consent to be changed" - required: true - type: string - - name: accepted - in: query - description: "Whether user accepted the consent (default = true)" - required: false - type: boolean -definitions: - Profile: - type: object - properties: - name: - type: string - address: - type: string - postCode: - type: string - city: - type: string - country: - type: string - email: - type: string - format: email - referralId: - type: string - required: - - email - SubscriptionList: - type: array - items: - $ref: '#/definitions/Subscription' - Subscription: - type: object - properties: - msisdn: - description: "Mobile number for this subscription" - type: string - BundleList: - type: array - items: - $ref: '#/definitions/Bundle' - Bundle: - type: object - properties: - id: - description: "Bundle ID" - type: string - balance: - description: "Balance units in this bundle" - type: integer - format: int64 - SubscriptionStatus: - type: object - properties: - remaining: - description: "Remaining data" - type: integer - format: int64 - purchaseRecords: - description: "List of Purchases" - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecordList: - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecord: - type: object - properties: - id: - description: "Purchase Record ID" - type: string - msisdn: - description: "Deprecated: The MSISDN for which the purchase was made." - type: string - timestamp: - description: "The time stamp of the purchase" - type: integer - format: int64 - product: - $ref: '#/definitions/Product' - required: - - timestamp - - product - ProductList: - type: array - items: - $ref: '#/definitions/Product' - Product: - type: object - properties: - sku: - description: "A unique Id representing a SKU" - type: string - price: - $ref: '#/definitions/Price' - properties: - type: object - presentation: - type: object - required: - - sku - - price - PaymentSourceList: - type: array - items: - $ref: '#/definitions/PaymentSource' - PaymentSource: - type: object - properties: - id: - description: "The identifier for the source" - type: string - type: - description: "The type of source" - type: string - details: - description: "All information stored with the source" - type: object - additionalProperties: true - required: - - id - - type - ConsentList: - type: array - items: - $ref: '#/definitions/Consent' - Consent: - type: object - properties: - consentId: - description: "The identifier of the consent" - type: string - description: - description: "A description of the consent" - type: string - accepted: - description: "Whether user has accepted the consent or not" - type: boolean - Price: - type: object - properties: - amount: - description: "A positive integer in the smallest currency unit" - type: integer - minimum: 0 - currency: - description: "ISO 4217 currency code (three letter alphabetic code)" - type: string - required: - - amount - - currency - ApplicationToken: - type: object - properties: - token: - description: "Application token" - type: string - applicationID: - description: "Uniquely identifier for the app instance" - type: string - tokenType: - description: "Type of application token (FCM)" - type: string - required: - - token - - applicationID - PseudonymEntity: - type: object - properties: - sourceId: - type: string - pseudonym: - type: string - start: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - end: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - required: - - sourceId - - pseudonym - - start - - end - Person: - type: object - properties: - name: - type: string - required: - - name - PersonList: - type: array - items: - $ref: '#/definitions/Person' - ActivePseudonyms: - type: object - properties: - current: - $ref: '#/definitions/PseudonymEntity' - next: - $ref: '#/definitions/PseudonymEntity' - required: - - current - - next -securityDefinitions: - auth0_jwt: - authorizationUrl: "https://ostelco.eu.auth0.com/authorize" - flow: "implicit" - type: "oauth2" - x-google-issuer: "https://ostelco.eu.auth0.com/" - x-google-jwks_uri: "https://ostelco.eu.auth0.com/.well-known/jwks.json" - x-google-audiences: "http://google_api" diff --git a/prime/infra/prime-direct-values.yaml b/prime/infra/prime-direct-values.yaml new file mode 100644 index 000000000..8a3f92b29 --- /dev/null +++ b/prime/infra/prime-direct-values.yaml @@ -0,0 +1,282 @@ +# DEV values for prime. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +dnsPrefix: "prime-direct-" +dnsSuffix: ".test.oya.world" + +podAutoscaling: + enabled: false + #minReplicas: 1 + #maxReplicas: 5 + #targetCPUUtilizationPercentage: 70 + +cronjobs: + extractor: + enabled: true + image: eu.gcr.io/pi-ostelco-dev/bq-metrics-extractor + tag: "1.3.212.1.0-2d41d62b-dev" + dataset_project: pi-ostelco-dev + shredder: + enabled: true + image: eu.gcr.io/pi-ostelco-dev/scaninfo-shredder + tag: "1.0.0-6052932a-dev" + dataset_project: pi-ostelco-dev + dev: true + +prime: + image: eu.gcr.io/pi-ostelco-dev/prime + tag: 63114190f + pullPolicy: Always + configDataBucket: "gs://pi-ostelco-dev-prime-files/dev" + + env: + FIREBASE_ROOT_PATH: direct + NEO4J_HOST: neo4j-neo4j.neo4j.svc.cluster.local + SLACK_CHANNEL: prime-alerts + DATASTORE_NAMESPACE: direct + DATA_TRAFFIC_TOPIC: data-traffic + PURCHASE_INFO_TOPIC: purchase-info + ACTIVE_USERS_TOPIC: active-users + STRIPE_EVENT_TOPIC: stripe-event + STRIPE_EVENT_STORE_SUBSCRIPTION: stripe-event-store-sub + STRIPE_EVENT_REPORT_SUBSCRIPTION: stripe-event-report-sub + GCP_PROJECT_ID: pi-ostelco-dev + ACTIVATE_TOPIC_ID: ocs-activate + CCR_SUBSCRIPTION_ID: ocs-ccr-sub + GOOGLE_APPLICATION_CREDENTIALS: /secret/prime-service-account.json + MY_INFO_API_URI: https://myinfosgstg.api.gov.sg/test/v2 + MY_INFO_API_REALM: direct + MY_INFO_REDIRECT_URI: https://dl-dev.oya.world/links/myinfo + + secretVolumes: + - secretName: "prime-sa-key" + containerMountPath: "/secret" + - secretName: "simmgr-test-secrets" + containerMountPath: "/certs" + secretKey: idemiaClientCert + secretPath: idemia-client-cert.jks + - secretName: "scaninfo-keysets" + containerMountPath: "/scaninfo-keysets" + + envFromSecret: + - name: SLACK_WEBHOOK_URI + secretName: slack-secrets + secretKey: slackWebHookUri + - name: STRIPE_API_KEY + secretName: stripe-secrets + secretKey: stripeApiKey + - name: STRIPE_ENDPOINT_SECRET + secretName: stripe-secrets + secretKey: stripeEndpointSecret + - name: SCANINFO_STORAGE_BUCKET + secretName: scaninfo-secrets + secretKey: bucketName + - name: SCANINFO_MASTERKEY_URI + secretName: scaninfo-keys + secretKey: masterKeyUri + - name: JUMIO_API_TOKEN + secretName: jumio-secrets + secretKey: apiToken + - name: JUMIO_API_SECRET + secretName: jumio-secrets + secretKey: apiSecret + - name: MY_INFO_API_CLIENT_ID + secretName: myinfo-secrets + secretKey: apiClientId + - name: MY_INFO_API_CLIENT_SECRET + secretName: myinfo-secrets + secretKey: apiClientSecret + - name: MY_INFO_SERVER_PUBLIC_KEY + secretName: myinfo-secrets + secretKey: serverPublicKey + - name: MY_INFO_CLIENT_PRIVATE_KEY + secretName: myinfo-secrets + secretKey: clientPrivateKey + - name: DB_USER + secretName: simmgr-test-secrets + secretKey: dbUser + - name: DB_PASSWORD + secretName: simmgr-test-secrets + secretKey: dbPassword + - name: DB_URL + secretName: simmgr-test-secrets + secretKey: dbUrl + - name: WG2_USER + secretName: simmgr-test-secrets + secretKey: wg2User + - name: WG2_API_KEY + secretName: simmgr-test-secrets + secretKey: wg2ApiKey + - name: WG2_ENDPOINT + secretName: simmgr-test-secrets + secretKey: wg2Endpoint + - name: ES2PLUS_ENDPOINT + secretName: simmgr-test-secrets + secretKey: es2plusEndpoint + - name: ES9PLUS_ENDPOINT + secretName: simmgr-test-secrets + secretKey: es9plusEndpoint + - name: FUNCTION_REQUESTER_IDENTIFIER + secretName: simmgr-test-secrets + secretKey: functionRequesterIdentifier + - name: MANDRILL_API_KEY + secretName: mandrill-secrets + secretKey: mandrillApiKey + + ports: + - 8080 + - 8081 + - 8082 + - 8083 + resources: + requests: + cpu: 100m + memory: 300Mi + livenessProbe: {} + readinessProbe: {} + annotations: + prometheus.io/scrape: 'true' + prometheus.io/path: '/prometheus-metrics' + prometheus.io/port: '8081' + + +canary: {} + # weight: 25 + # headers: # only route requests with these headers to the canary service + # x-mode: canary + # tag: e449ed672 + +cloudsqlProxy: + enabled: true + instanceConnectionName: "pi-ostelco-dev:europe-west1:sim-manager" + secretName: "prime-sa-key" + secretKey: "prime-service-account.json" + +esp: + image: gcr.io/endpoints-release/endpoints-runtime + tag: 1 + pullPolicy: IfNotPresent + +ocsEsp: + enabled: true + env: {} + endpointAddress: ocs.dev.oya.world + ports: + http2_port: 9000 + ssl_port: 8443 + secretVolumes: + - secretName: test-oya-tls + containerMountPath: /etc/nginx/ssl + type: ssl + +apiEsp: + enabled: true + env: {} + endpointAddress: api.dev.oya.world + ports: + http2_port: 9002 + +metricsEsp: + enabled: true + env: {} + endpointAddress: metrics.dev.oya.world + ports: + http2_port: 9004 + ssl_port: 9443 + secretVolumes: + - secretName: test-oya-tls + containerMountPath: /etc/nginx/ssl + type: ssl + +alvinApiEsp: + enabled: true + env: {} + endpointAddress: alvin-api.dev.oya.world + ports: + http_port: 9008 + +houstonApiEsp: + enabled: true + env: {} + endpointAddress: houston-api.dev.oya.world + ports: + http_port: 9006 + +services: + ocs: + name: ocs + type: LoadBalancer + port: 443 + targetPort: 8443 + portName: grpc + # host: ocs # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + # grpcOrHttp2: true + api: + name: api + type: ClusterIP + port: 80 + targetPort: 9002 + portName: http + host: api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + grpcOrHttp2: true + ambassadorMappingOptions: + timeout_ms: 600000 + metrics: + name: metrics + type: LoadBalancer + port: 443 + targetPort: 9443 + portName: grpc + # loadBalancerIP: x.y.z.n + # host: metrics # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + # grpcOrHttp2: true + prime-houston-api: + name: houston-api + type: ClusterIP + port: 80 + targetPort: 9006 + portName: http + host: houston-api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + prime-alvin-api: + name: alvin-api + type: ClusterIP + port: 80 + targetPort: 9008 + portName: http + host: alvin-api # the host name is formulated from concatenating: dnsPrefix, this host, and dnsSuffix + dwadmin-service: + name: dwadmin-service + type: ClusterIP + port: 8081 + targetPort: 8081 + portName: http + smdpplus: + name: smdpplus + type: ClusterIP + port: 80 + targetPort: 8080 + portName: http + host: smdpplus + clientCert: true + caCert: smdp-cacert.dev # secretname.namespace + +certs: + enabled: true + dnsProvider: dev-clouddns + issuer: letsencrypt-production + tlsSecretName: test-oya-tls + hosts: + - '*.test.oya.world' + +disruptionBudget: + enabled: false + minAvailable: 1 + +nodeSelector: {} + +tolerations: [] + +affinity: {} \ No newline at end of file diff --git a/prime/infra/prod/metrics-api.yaml b/prime/infra/prod/metrics-api.yaml index 7badcd6a5..ae7b151c7 100644 --- a/prime/infra/prod/metrics-api.yaml +++ b/prime/infra/prod/metrics-api.yaml @@ -2,7 +2,7 @@ type: google.api.Service config_version: 3 -name: metrics.ostelco.org +name: metrics.oya.world title: Prime Metrics Reporter Service gRPC API @@ -18,12 +18,12 @@ usage: authentication: providers: - id: google_service_account - issuer: prime-service-account@pantel-2decb.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@pantel-2decb.iam.gserviceaccount.com + issuer: prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com + jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com audiences: > - https://metrics.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - metrics.ostelco.org/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, - metrics.ostelco.org + https://metrics.oya.world/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, + metrics.oya.world/org.ostelco.prime.metrics.api.OcsgwAnalyticsService, + metrics.oya.world rules: - selector: "*" requirements: diff --git a/prime/infra/prod/neo4j.yaml b/prime/infra/prod/neo4j.yaml deleted file mode 100644 index e647ca8d5..000000000 --- a/prime/infra/prod/neo4j.yaml +++ /dev/null @@ -1,95 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: neo4j - labels: - app: neo4j - component: core -spec: - clusterIP: None - ports: - - port: 7474 - targetPort: 7474 - name: browser - - port: 6362 - targetPort: 6362 - name: backup - selector: - app: neo4j - component: core ---- -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: faster -provisioner: kubernetes.io/gce-pd -parameters: - type: pd-ssd ---- -apiVersion: "apps/v1beta1" -kind: StatefulSet -metadata: - name: neo4j-core -spec: - serviceName: neo4j - replicas: 3 - template: - metadata: - labels: - app: neo4j - component: core - spec: - containers: - - name: neo4j - image: "neo4j:3.4.8-enterprise" - imagePullPolicy: "IfNotPresent" - env: - - name: NEO4J_dbms_mode - value: CORE - - name: NUMBER_OF_CORES - value: "3" - - name: NEO4J_dbms_security_auth__enabled - value: "false" - - name: NEO4J_causal__clustering_discovery__type - value: DNS - - name: NEO4J_causal__clustering_initial__discovery__members - value: "neo4j.default.svc.cluster.local:5000" - - name: NEO4J_ACCEPT_LICENSE_AGREEMENT - value: "yes" - command: - - "/bin/bash" - - "-ecx" - - | - export NEO4J_dbms_connectors_default__advertised__address=$(hostname -f) - export NEO4J_causal__clustering_discovery__advertised__address=$(hostname -f):5000 - export NEO4J_causal__clustering_transaction__advertised__address=$(hostname -f):6000 - export NEO4J_causal__clustering_raft__advertised__address=$(hostname -f):7000 - exec /docker-entrypoint.sh "neo4j" - ports: - - containerPort: 5000 - name: discovery - - containerPort: 7000 - name: raft - - containerPort: 6000 - name: tx - - containerPort: 7474 - name: browser - - containerPort: 7687 - name: bolt - - containerPort: 6362 - name: backup - securityContext: - privileged: true - volumeMounts: - - name: datadir - mountPath: "/data" - volumeClaimTemplates: - - metadata: - name: datadir - spec: - accessModes: - - ReadWriteOnce - storageClassName: "faster" - resources: - requests: - storage: "10Gi" \ No newline at end of file diff --git a/prime/infra/prod/ocs-api.yaml b/prime/infra/prod/ocs-api.yaml index 9e8d7cc75..a890db711 100644 --- a/prime/infra/prod/ocs-api.yaml +++ b/prime/infra/prod/ocs-api.yaml @@ -2,7 +2,7 @@ type: google.api.Service config_version: 3 -name: ocs.ostelco.org +name: ocs.oya.world title: OCS Service gRPC API @@ -18,13 +18,13 @@ usage: authentication: providers: - id: google_service_account - issuer: prime-service-account@pantel-2decb.iam.gserviceaccount.com - jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@pantel-2decb.iam.gserviceaccount.com + issuer: prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com + jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/prime-service-account@GCP_PROJECT_ID.iam.gserviceaccount.com audiences: > - https://ocs.ostelco.org/org.ostelco.ocs.api.OcsService, - ocs.ostelco.org/org.ostelco.ocs.api.OcsService, - ocs.ostelco.org + https://ocs.oya.world/org.ostelco.ocs.api.OcsService, + ocs.oya.world/org.ostelco.ocs.api.OcsService, + ocs.oya.world rules: - selector: "*" requirements: - - provider_id: google_service_account + - provider_id: google_service_account \ No newline at end of file diff --git a/prime/infra/prod/prime-client-api.yaml b/prime/infra/prod/prime-client-api.yaml deleted file mode 100644 index 023650751..000000000 --- a/prime/infra/prod/prime-client-api.yaml +++ /dev/null @@ -1,598 +0,0 @@ -swagger: "2.0" -info: - title: "Ostelco API" - description: "The client API for Panacea." - version: "1.0.0" -host: "api.ostelco.org" -x-google-endpoints: - - name: "api.ostelco.org" - allowCors: true -schemes: - - "https" -paths: - "/profile": - get: - description: "Get profile for the user (email-id present in the bearer token)." - produces: - - application/json - operationId: "getProfile" - responses: - 200: - description: "Get the profile for this user." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - post: - description: "Create a new profile." - consumes: - - application/json - produces: - - application/json - operationId: "createProfile" - parameters: - - name: profile - in: body - description: The profile to create. - schema: - $ref: '#/definitions/Profile' - - name: referred_by - in: query - description: "Referral ID of user who has invited this user" - type: string - responses: - 201: - description: "Successfully created the profile." - schema: - $ref: '#/definitions/Profile' - security: - - auth0_jwt: [] - put: - description: "Update an existing profile." - consumes: - - application/json - produces: - - application/json - operationId: "updateProfile" - parameters: - - in: body - name: profile - description: The updated profile. - schema: - $ref: '#/definitions/Profile' - responses: - 200: - description: "Successfully updated the profile." - schema: - $ref: '#/definitions/Profile' - 404: - description: "Profile not found." - security: - - auth0_jwt: [] - "/applicationtoken": - post: - description: "Store application token" - consumes: - - application/json - produces: - - application/json - operationId: "storeApplicationToken" - parameters: - - name: applicationToken - in: body - description: application token - schema: - $ref: '#/definitions/ApplicationToken' - responses: - 201: - description: "Successfully stored token." - schema: - $ref: '#/definitions/ApplicationToken' - 404: - description: "User not found." - 507: - description: "Not able to store token." - security: - - auth0_jwt: [] - "/paymentSources": - get: - description: "Get all payment sources for the user." - produces: - - application/json - operationId: "listSources" - responses: - 200: - description: "List of payment sources." - schema: - $ref: '#/definitions/PaymentSourceList' - 404: - description: "No user found." - security: - - auth0_jwt: [] - post: - description: "Add a new payment source for user" - produces: - - application/json - operationId: "createSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be added to user" - required: true - type: string - responses: - 201: - description: "Successfully added source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - put: - description: "Set the source as default for user" - produces: - - application/json - operationId: "setDefaultSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the default source" - required: true - type: string - responses: - 200: - description: "Successfully set as default source to user" - schema: - $ref: '#/definitions/PaymentSource' - 404: - description: "User not found." - security: - - auth0_jwt: [] - delete: - description: "Remove a payment source for user" - produces: - - application/json - operationId: "removeSource" - parameters: - - name: sourceId - in: query - description: "The stripe-id of the source to be removed" - required: true - type: string - responses: - 200: - description: "Successfully removed the source" - schema: - $ref: '#/definitions/PaymentSource' - 400: - description: "The source could not be removed" - 404: - description: "No such source for user" - security: - - auth0_jwt: [] - "/products": - get: - description: "Get all products for the user." - produces: - - application/json - operationId: "getAllProducts" - responses: - 200: - description: "List of products." - schema: - $ref: '#/definitions/ProductList' - 404: - description: "No products found for the user." - security: - - auth0_jwt: [] - "/products/{sku}": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "buyProductDeprecated" - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - parameters: - - name: sku - in: path - description: SKU to be purchased - required: true - type: string - "/products/{sku}/purchase": - post: - description: "Buy the product specified in sku parameter." - produces: - - application/json - - text/plain - operationId: "purchaseProduct" - parameters: - - name: sku - in: path - description: "SKU to be purchased" - required: true - type: string - - name: sourceId - in: query - description: "The stripe-id of the source to be used for this purchase (if empty, use default source)" - required: false - type: string - - name: saveCard - in: query - description: "Whether to save this card as a source for this user (default = false)" - required: false - type: boolean - responses: - 201: - description: "Successfully purchased the product." - schema: - $ref: '#/definitions/Product' - 404: - description: "Product not found." - security: - - auth0_jwt: [] - "/purchases": - get: - description: "Get list of all purchases." - produces: - - application/json - - text/plain - operationId: "getPurchaseHistory" - responses: - 200: - description: "List of Purchase Records." - schema: - $ref: '#/definitions/PurchaseRecordList' - 404: - description: "No Purchase Records found for the user." - security: - - auth0_jwt: [] - "/subscriptions": - get: - description: "Get subscription (msisdn) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptions" - responses: - 200: - description: "Get subscriptions for this user." - schema: - $ref: '#/definitions/SubscriptionList' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/bundles": - get: - description: "Get bundles (balance) for the user (identified by bearer token)." - produces: - - application/json - operationId: "getBundles" - responses: - 200: - description: "Get bundles for this user." - schema: - $ref: '#/definitions/BundleList' - 404: - description: "No bundle found for this user." - security: - - auth0_jwt: [] - "/subscription/status": - get: - description: "Get subscription status for the user (identified by bearer token)." - produces: - - application/json - operationId: "getSubscriptionStatus" - responses: - 200: - description: "Get the subscription status for this user." - schema: - $ref: '#/definitions/SubscriptionStatus' - 404: - description: "No subscription status found for this user." - security: - - auth0_jwt: [] - "/subscription/activePseudonyms": - get: - description: "Get currently active pseudonyms for the user's msisdn (identified by bearer token)." - produces: - - application/json - operationId: "getActivePseudonyms" - responses: - 200: - description: "Get active pseudonyms for the user's msisdn." - schema: - $ref: '#/definitions/ActivePseudonyms' - 404: - description: "No subscription found for this user." - security: - - auth0_jwt: [] - "/referred": - get: - description: "Get list of people whom the user has referred to." - produces: - - application/json - operationId: "getReferred" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/PersonList' - 404: - description: "No referrals found for this user." - security: - - auth0_jwt: [] - "/referred/by": - get: - description: "Get the people who had referred this user." - produces: - - application/json - operationId: "getReferredBy" - responses: - 200: - description: "List of people whom this person has referred to." - schema: - $ref: '#/definitions/Person' - 404: - description: "No 'referred by' found for this user." - security: - - auth0_jwt: [] - "/consents": - get: - description: "Get all consents for the user." - produces: - - application/json - operationId: "getConsents" - responses: - 200: - description: "List of consents." - schema: - $ref: '#/definitions/ConsentList' - 404: - description: "No consents found for the user." - security: - - auth0_jwt: [] - "/consents/{consent-id}": - put: - description: "Change the value for the specified consent." - operationId: "updateConsent" - responses: - 200: - description: "Successfully updated the consent." - 404: - description: "Consent not found." - security: - - auth0_jwt: [] - parameters: - - name: consent-id - in: path - description: "Id of the consent to be changed" - required: true - type: string - - name: accepted - in: query - description: "Whether user accepted the consent (default = true)" - required: false - type: boolean -definitions: - Profile: - type: object - properties: - name: - type: string - address: - type: string - postCode: - type: string - city: - type: string - country: - type: string - email: - type: string - format: email - referralId: - type: string - required: - - email - SubscriptionList: - type: array - items: - $ref: '#/definitions/Subscription' - Subscription: - type: object - properties: - msisdn: - description: "Mobile number for this subscription" - type: string - BundleList: - type: array - items: - $ref: '#/definitions/Bundle' - Bundle: - type: object - properties: - id: - description: "Bundle ID" - type: string - balance: - description: "Balance units in this bundle" - type: integer - format: int64 - SubscriptionStatus: - type: object - properties: - remaining: - description: "Remaining data" - type: integer - format: int64 - purchaseRecords: - description: "List of Purchases" - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecordList: - type: array - items: - $ref: '#/definitions/PurchaseRecord' - PurchaseRecord: - type: object - properties: - id: - description: "Purchase Record ID" - type: string - msisdn: - description: "Deprecated: The MSISDN for which the purchase was made." - type: string - timestamp: - description: "The time stamp of the purchase" - type: integer - format: int64 - product: - $ref: '#/definitions/Product' - required: - - timestamp - - product - ProductList: - type: array - items: - $ref: '#/definitions/Product' - Product: - type: object - properties: - sku: - description: "A unique Id representing a SKU" - type: string - price: - $ref: '#/definitions/Price' - properties: - type: object - presentation: - type: object - required: - - sku - - price - PaymentSourceList: - type: array - items: - $ref: '#/definitions/PaymentSource' - PaymentSource: - type: object - properties: - id: - description: "The identifier for the source" - type: string - type: - description: "The type of source" - type: string - details: - description: "All information stored with the source" - type: object - additionalProperties: true - required: - - id - - type - ConsentList: - type: array - items: - $ref: '#/definitions/Consent' - Consent: - type: object - properties: - consentId: - description: "The identifier of the consent" - type: string - description: - description: "A description of the consent" - type: string - accepted: - description: "Whether user has accepted the consent or not" - type: boolean - Price: - type: object - properties: - amount: - description: "A positive integer in the smallest currency unit" - type: integer - minimum: 0 - currency: - description: "ISO 4217 currency code (three letter alphabetic code)" - type: string - required: - - amount - - currency - ApplicationToken: - type: object - properties: - token: - description: "Application token" - type: string - applicationID: - description: "Uniquely identifier for the app instance" - type: string - tokenType: - description: "Type of application token (FCM)" - type: string - required: - - token - - applicationID - PseudonymEntity: - type: object - properties: - sourceId: - type: string - pseudonym: - type: string - start: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - end: - description: "The start time stamp for this pseudonym" - type: integer - format: int64 - required: - - sourceId - - pseudonym - - start - - end - Person: - type: object - properties: - name: - type: string - required: - - name - PersonList: - type: array - items: - $ref: '#/definitions/Person' - ActivePseudonyms: - type: object - properties: - current: - $ref: '#/definitions/PseudonymEntity' - next: - $ref: '#/definitions/PseudonymEntity' - required: - - current - - next -securityDefinitions: - auth0_jwt: - authorizationUrl: "https://ostelco.eu.auth0.com/authorize" - flow: "implicit" - type: "oauth2" - x-google-issuer: "https://ostelco.eu.auth0.com/" - x-google-jwks_uri: "https://ostelco.eu.auth0.com/.well-known/jwks.json" - x-google-audiences: "http://google_api" \ No newline at end of file diff --git a/prime/infra/prod/prime-customer-api.yaml b/prime/infra/prod/prime-customer-api.yaml new file mode 100644 index 000000000..d05452fb2 --- /dev/null +++ b/prime/infra/prod/prime-customer-api.yaml @@ -0,0 +1,967 @@ +swagger: "2.0" +info: + title: "Ostelco API" + description: "The customer API." + version: "1.0.0" +host: "api.oya.world" +x-google-endpoints: + - name: "api.oya.world" + allowCors: true +schemes: + - "https" +paths: + "/customer/stripe-ephemeral-key": + get: + description: "Get Stripe Ephemeral key." + produces: + - application/json + operationId: "getStripeEphemeralKey" + parameters: + - name: api_version + in: query + description: "Stripe API version" + type: string + format: email + responses: + 200: + description: "Get Stripe Ephemeral key." + schema: + type: string + security: + - auth0_jwt: [] + - firebase: [] + "/context": + get: + description: "Get context which is customer and region details." + produces: + - application/json + operationId: "getContext" + responses: + 200: + description: "Get the customer context." + schema: + $ref: '#/definitions/Context' + 404: + description: "Customer not found." + security: + - auth0_jwt: [] + - firebase: [] + "/customer": + get: + description: "Get customer info (email-id present in the bearer token)." + produces: + - application/json + operationId: "getCustomer" + responses: + 200: + description: "Get the customer info." + schema: + $ref: '#/definitions/Customer' + 404: + description: "Customer not found." + security: + - auth0_jwt: [] + - firebase: [] + post: + description: "Create a new customer." + consumes: + - application/json + produces: + - application/json + operationId: "createCustomer" + parameters: + - name: nickname + in: query + description: "Nickname of the customer" + type: string + required: true + - name: contactEmail + in: query + description: "Contact Email of the customer" + type: string + required: true + - name: referredBy + in: query + description: "Referral ID of user who has invited this user" + type: string + responses: + 201: + description: "Successfully created the customer." + schema: + $ref: '#/definitions/Customer' + 400: + description: "Incomplete customer info" + 500: + description: "Failed to store customer" + security: + - auth0_jwt: [] + - firebase: [] + put: + description: "Update an existing customer." + consumes: + - application/json + produces: + - application/json + operationId: "updateCustomer" + parameters: + - name: nickname + in: query + description: "Nickname of the customer" + type: string + - name: contactEmail + in: query + description: "Contact Email of the customer" + type: string + responses: + 200: + description: "Successfully updated the customer." + schema: + $ref: '#/definitions/Customer' + 400: + description: "Incomplete Customer info." + 404: + description: "Customer not found." + 500: + description: "Failed to update customer info." + security: + - auth0_jwt: [] + - firebase: [] + delete: + description: "Remove customer." + produces: + - application/json + operationId: "removeCustomer" + responses: + 204: + description: "Remove customer." + 404: + description: "Customer not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions": + get: + description: "Get all regions and region details like SIM Profiles for that region." + produces: + - application/json + operationId: "getAllRegions" + responses: + 200: + description: "List of all Region Details" + schema: + $ref: '#/definitions/RegionDetailsList' + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}": + get: + description: "Get region details like SIM Profiles for a region." + produces: + - application/json + operationId: "getRegion" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 200: + description: "Region Details for a region" + schema: + $ref: '#/definitions/RegionDetails' + 404: + description: "Region not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/kyc/jumio/scans": + post: + description: "Get a new Id for eKYC scanning." + produces: + - application/json + operationId: "createNewJumioKycScanId" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 201: + description: "Successfully retrieved new ScanId." + schema: + $ref: '#/definitions/ScanInformation' + 404: + description: "Region not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/kyc/jumio/scans/{scanId}": + get: + description: "Get status of eKYC scan." + produces: + - application/json + operationId: "getScan" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + - name: scanId + in: path + description: "Id of the scan being queried" + required: true + type: string + responses: + 200: + description: "Successfully retrieved Scan information." + schema: + $ref: '#/definitions/ScanInformation' + 404: + description: "Region or Scan not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/sg/kyc/myInfo/{authorisationCode}": + get: + description: "Get Customer Data from Singapore MyInfo service." + produces: + - application/json + operationId: "getCustomerMyInfoData" + parameters: + - name: authorisationCode + in: path + description: "Authorisation Code" + required: true + type: string + responses: + 200: + description: "Successfully retrieved Customer Data from MyInfo service." + schema: + type: object + 404: + description: "Person Data not found." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/sg/kyc/dave/{nricFinId}": + get: + description: "Get Customer Data from Singapore MyInfo service." + produces: + - application/json + operationId: "checkNricFinId" + parameters: + - name: nricFinId + in: path + description: "NRIC/FIN ID for Singapore" + required: true + type: string + responses: + 204: + description: "Successfully verified Singapore's NRIC/FIN ID for the Customer." + 400: + description: "Invalid NRIC/FIN ID" + security: + - auth0_jwt: [] + - firebase: [] + "/regions/sg/kyc/profile": + put: + description: "Update Singapore Customer's address and phone number." + produces: + - application/json + operationId: "updateDetails" + parameters: + - name: address + in: query + description: "Customer's Address" + required: true + type: string + - name: phoneNumber + in: query + description: "Customer's phone number" + required: true + type: string + responses: + 204: + description: "Successfully updated customer's details." + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/simProfiles": + get: + description: "Get SIM profile for the user (identified by bearer token)." + produces: + - application/json + operationId: "getSimProfiles" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 200: + description: "Get SIM profiles for this user." + schema: + $ref: '#/definitions/SimProfileList' + 404: + description: "Not allowed for this region, or No SIM profiles found for this user for this region." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + post: + description: "Provision SIM Profile for the user (identified by bearer token)." + produces: + - application/json + operationId: "provisionSimProfile" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + - name: profileType + in: query + description: "Profile Type" + type: string + responses: + 201: + description: "Provisioned SIM profile for this user." + schema: + $ref: '#/definitions/SimProfile' + 400: + description: "Not allowed for this region, or missing parameters." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + "/regions/{regionCode}/subscriptions": + get: + description: "Get subscription (msisdn) for the user (identified by bearer token)." + produces: + - application/json + operationId: "getSubscriptionsForRegion" + parameters: + - name: regionCode + in: path + description: "Region code" + required: true + type: string + responses: + 200: + description: "Get subscriptions for a region for this user." + schema: + $ref: '#/definitions/SubscriptionList' + 404: + description: "No subscription found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/subscriptions": + get: + description: "Get subscription (msisdn) for the user (identified by bearer token)." + produces: + - application/json + operationId: "getSubscriptions" + responses: + 200: + description: "Get subscriptions for this user." + schema: + $ref: '#/definitions/SubscriptionList' + 404: + description: "No subscription found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/applicationToken": + post: + description: "Store application token" + consumes: + - application/json + produces: + - application/json + operationId: "storeApplicationToken" + parameters: + - name: applicationToken + in: body + description: application token + schema: + $ref: '#/definitions/ApplicationToken' + responses: + 201: + description: "Successfully stored token." + schema: + $ref: '#/definitions/ApplicationToken' + 400: + description: "Token malformed. Not able to store" + 404: + description: "User not found." + 500: + description: "Not able to store token." + security: + - auth0_jwt: [] + - firebase: [] + "/paymentSources": + get: + description: "Get all payment sources for the user." + produces: + - application/json + operationId: "listSources" + responses: + 200: + description: "List of payment sources." + schema: + $ref: '#/definitions/PaymentSourceList' + 404: + description: "No user found." + 503: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + post: + description: "Add a new payment source for user" + produces: + - application/json + operationId: "createSource" + parameters: + - name: sourceId + in: query + description: "The stripe-id of the source to be added to user" + required: true + type: string + responses: + 201: + description: "Successfully added source to user" + schema: + $ref: '#/definitions/PaymentSource' + 400: + description: "Invalid source" + 404: + description: "User not found." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + put: + description: "Set the source as default for user" + produces: + - application/json + operationId: "setDefaultSource" + parameters: + - name: sourceId + in: query + description: "The stripe-id of the default source" + required: true + type: string + responses: + 200: + description: "Successfully set as default source to user" + schema: + $ref: '#/definitions/PaymentSource' + 400: + description: "Invalid source" + 404: + description: "User not found." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + delete: + description: "Remove a payment source for user" + produces: + - application/json + operationId: "removeSource" + parameters: + - name: sourceId + in: query + description: "The stripe-id of the source to be removed" + required: true + type: string + responses: + 200: + description: "Successfully removed the source" + schema: + $ref: '#/definitions/PaymentSource' + 400: + description: "Invalid source, or The source could not be removed" + 404: + description: "No such source for user" + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + "/products": + get: + description: "Get all products for the user." + produces: + - application/json + operationId: "getAllProducts" + responses: + 200: + description: "List of products." + schema: + $ref: '#/definitions/ProductList' + 404: + description: "No products found for the user." + security: + - auth0_jwt: [] + - firebase: [] + "/products/{sku}/purchase": + post: + description: "Buy the product specified in sku parameter." + produces: + - application/json + - text/plain + operationId: "purchaseProduct" + parameters: + - name: sku + in: path + description: "SKU to be purchased" + required: true + type: string + - name: sourceId + in: query + description: "The stripe-id of the source to be used for this purchase (if empty, use default source)" + required: false + type: string + - name: saveCard + in: query + description: "Whether to save this card as a source for this user (default = false)" + required: false + type: boolean + responses: + 201: + description: "Successfully purchased the product." + schema: + $ref: '#/definitions/Product' + 404: + description: "Product not found." + security: + - auth0_jwt: [] + - firebase: [] + "/purchases": + get: + description: "Get list of all purchases." + produces: + - application/json + - text/plain + operationId: "getPurchaseHistory" + responses: + 200: + description: "List of Purchase Records." + schema: + $ref: '#/definitions/PurchaseRecordList' + 400: + description: "Not allowed to charge this source" + 404: + description: "No Purchase Records found for the user." + 500: + description: "Service Unavailable" + security: + - auth0_jwt: [] + - firebase: [] + "/bundles": + get: + description: "Get bundles (balance) for the user (identified by bearer token)." + produces: + - application/json + operationId: "getBundles" + responses: + 200: + description: "Get bundles for this user." + schema: + $ref: '#/definitions/BundleList' + 404: + description: "No bundle found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/referred": + get: + description: "Get list of people whom the user has referred to." + produces: + - application/json + operationId: "getReferred" + responses: + 200: + description: "List of people whom this person has referred to." + schema: + $ref: '#/definitions/PersonList' + 404: + description: "No referrals found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/referred/by": + get: + description: "Get the people who had referred this user." + produces: + - application/json + operationId: "getReferredBy" + responses: + 200: + description: "List of people whom this person has referred to." + schema: + $ref: '#/definitions/Person' + 404: + description: "No 'referred by' found for this user." + security: + - auth0_jwt: [] + - firebase: [] + "/graphql": + post: + description: "GraphQL endpoint" + consumes: + - application/json + produces: + - application/json + operationId: "graphql" + responses: + 200: + description: "Success" + schema: + type: object + 404: + description: "Not found" + security: + - auth0_jwt: [] + - firebase: [] + parameters: + - name: "request" + in: body + description: "GraphQL Request." + schema: + $ref: '#/definitions/GraphQLRequest' + +definitions: + Context: + type: object + properties: + customer: + $ref: '#/definitions/Customer' + regions: + $ref: '#/definitions/RegionDetailsList' + Customer: + type: object + properties: + id: + type: string + nickname: + type: string + contactEmail: + type: string + format: email + analyticsId: + type: string + referralId: + type: string + required: + - name + - email + RegionDetailsList: + type: array + items: + $ref: '#/definitions/RegionDetails' + RegionDetails: + type: object + properties: + region: + $ref: '#/definitions/Region' + status: + description: "Customer Status for this region" + type: string + enum: [ PENDING, APPROVED ] + kycStatusMap: + description: "Map of status for each KYC" + type: object + properties: + kycType: + $ref: '#/definitions/KycType' + additionalProperties: + $ref: '#/definitions/KycStatus' + example: + JUMIO: PENDING + MY_INFO: APPROVED + NRIC_FIN: REJECTED + ADDRESS_AND_PHONE_NUMBER: PENDING + simProfiles: + $ref: '#/definitions/SimProfileList' + KycType: + type: string + enum: [ JUMIO, MY_INFO, NRIC_FIN, ADDRESS_AND_PHONE_NUMBER ] + KycStatus: + type: string + enum: [ PENDING, REJECTED, APPROVED ] + Region: + type: object + properties: + id: + type: string + name: + type: string + SubscriptionList: + type: array + items: + $ref: '#/definitions/Subscription' + Subscription: + type: object + properties: + msisdn: + description: "Mobile number for this subscription" + type: string + BundleList: + type: array + items: + $ref: '#/definitions/Bundle' + Bundle: + type: object + properties: + id: + description: "Bundle ID" + type: string + balance: + description: "Balance units in this bundle" + type: integer + format: int64 + PurchaseRecordList: + type: array + items: + $ref: '#/definitions/PurchaseRecord' + PurchaseRecord: + type: object + properties: + id: + description: "Purchase Record ID" + type: string + msisdn: + description: "Deprecated: The MSISDN for which the purchase was made." + type: string + timestamp: + description: "The time stamp of the purchase" + type: integer + format: int64 + product: + $ref: '#/definitions/Product' + refund: + $ref: '#/definitions/Refund' + required: + - timestamp + - product + ProductList: + type: array + items: + $ref: '#/definitions/Product' + Product: + type: object + properties: + sku: + description: "A unique Id representing a SKU" + type: string + price: + $ref: '#/definitions/Price' + properties: + type: object + presentation: + type: object + required: + - sku + - price + ProductInfo: + type: object + properties: + id: + description: "A unique Id representing a SKU" + type: string + required: + - id + Refund: + type: object + properties: + id: + description: "A unique Id representing a refund object" + type: string + reason: + description: "Reason provided while refunding" + type: string + timestamp: + description: "The time stamp of the refund" + type: integer + format: int64 + required: + - id + - reason + - timestamp + PaymentSourceList: + type: array + items: + $ref: '#/definitions/PaymentSource' + PaymentSource: + type: object + properties: + id: + description: "The identifier for the source" + type: string + type: + description: "The type of source" + type: string + details: + description: "All information stored with the source" + type: object + additionalProperties: true + required: + - id + - type + Price: + type: object + properties: + amount: + description: "A positive integer in the smallest currency unit" + type: integer + minimum: 0 + currency: + description: "ISO 4217 currency code (three letter alphabetic code)" + type: string + required: + - amount + - currency + ApplicationToken: + type: object + properties: + token: + description: "Application token" + type: string + applicationID: + description: "Uniquely identifier for the app instance" + type: string + tokenType: + description: "Type of application token (FCM)" + type: string + required: + - token + - applicationID + Person: + type: object + properties: + name: + type: string + required: + - name + PersonList: + type: array + items: + $ref: '#/definitions/Person' + Plan: + type: object + properties: + name: + description: "An unique name representing the plan" + type: string + price: + $ref: '#/definitions/Price' + interval: + description: "The recurring period for the plan" + type: string + enum: [ day, week, month, year ] + intervalCount: + description: "Number of intervals in a period" + type: integer + default: 1 + minimum: 1 + properties: + description: "Free form key/value pairs" + type: object + additionalProperties: true + presentation: + description: "Pretty print version of plan" + type: object + additionalProperties: true + required: + - name + - price + - interval + PlanList: + type: array + items: + $ref: '#/definitions/Plan' + GraphQLRequest: + type: object + properties: + query: + description: "GraphQL query." + type: string + operationName: + description: "GraphQL Operation Name." + type: string + variables: + description: "GraphQL query variables." + type: object + ScanInformationList: + type: array + items: + $ref: '#/definitions/ScanInformation' + ScanInformation: + type: object + properties: + scanId: + description: "New scan Id for eKYC" + type: string + regionCode: + description: "Region code" + type: string + status: + description: "The status of the scan" + type: string + scanResult: + description: "The result from the vendor" + type: object + required: + - scanId + - status + SimProfileList: + type: array + items: + $ref: '#/definitions/SimProfile' + SimProfile: + type: object + properties: + iccId: + description: "ID of Sim Profile" + type: string + eSimActivationCode: + description: "eSIM activation code" + type: string + status: + description: "The status of the SIM profile, e.g. INSTALLED" + type: string + enum: [ NOT_READY, AVAILABLE_FOR_DOWNLOAD, DOWNLOADED, INSTALLED, ENABLED ] + alias: + description: "Human readable optional alias for this subscription" + type: string + required: + - iccId + - activationCode + - status +securityDefinitions: + auth0_jwt: + authorizationUrl: "" + flow: "implicit" + type: "oauth2" + x-google-issuer: "https://auth-dev.oya.world/" + x-google-jwks_uri: "https://auth-dev.oya.world/.well-known/jwks.json" + x-google-audiences: "http://google_api" + firebase: + authorizationUrl: "" + flow: "implicit" + type: "oauth2" + x-google-issuer: "https://securetoken.google.com/pi-ostelco-prod" + x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com" + x-google-audiences: "pi-ostelco-prod" diff --git a/prime/infra/prod/prime-houston-api.yaml b/prime/infra/prod/prime-houston-api.yaml new file mode 100644 index 000000000..133656dfb --- /dev/null +++ b/prime/infra/prod/prime-houston-api.yaml @@ -0,0 +1,526 @@ +swagger: "2.0" +info: + title: "Houston Admin API" + description: "The APIs for the Houston Admin Client." + version: "1.0.0" +host: "houston-api.oya.world" +x-google-endpoints: + - name: "houston-api.oya.world" + allowCors: true +schemes: + - "https" +paths: + "/profiles/{id}": + get: + description: "Get profile for the given email-id or msisdn (url encoded)." + produces: + - application/json + operationId: "getCustomer" + responses: + 200: + description: "Get the profile for this user." + schema: + $ref: '#/definitions/Profile' + 404: + description: "Profile not found." + security: + - auth0_jwt: [] + parameters: + - name: id + in: path + description: "The id of the user (msisdn or email)" + required: true + type: string + "/profiles/{email}/subscriptions": + get: + description: "Get subscription (msisdn) for the user." + produces: + - application/json + operationId: "getSubscriptions" + responses: + 200: + description: "Get subscriptions for this user." + schema: + $ref: '#/definitions/SubscriptionList' + 404: + description: "No subscription found for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/profiles/{email}/scans": + get: + description: "Get eKYC scan information for the user." + produces: + - application/json + operationId: "getAllScanInformation" + responses: + 200: + description: "Retrieved scan information for this user." + schema: + $ref: '#/definitions/ScanInformationList' + 404: + description: "No scan information found for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/profiles/{email}/plans": + get: + description: "Get all plans subscribed to by a customer" + produces: + - application/json + operationId: "getPlans" + security: + - auth0_jwt: [] + responses: + 200: + description: "Plans subscribed to" + schema: + $ref: '#/definitions/PlanList' + 404: + description: "No plans found" + parameters: + - name: email + in: path + description: "The email of the customer" + required: true + type: string + "/profiles/{email}/plans/{planId}": + post: + description: "Subscribe a customer to a plan" + produces: + - application/json + - text/plain + operationId: "attachPlan" + responses: + 201: + description: "The subscription was created successfully" + 400: + description: "Failed to create subscription" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the customer" + required: true + type: string + - name: planId + in: path + description: "The name of the plan to subscribe to" + required: true + type: string + delete: + description: "Remove a customer from a plan" + produces: + - application/json + - text/plain + operationId: "detachPlan" + responses: + 200: + description: "The subscription was removed successfully" + 400: + description: "Failed to remove subscription" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the customer" + required: true + type: string + - name: planId + in: path + description: "The name of the plan to remove the subscription for" + required: true + type: string + "/bundles/{email}": + get: + description: "Get bundles (balance) for the user (identified by email)." + produces: + - application/json + operationId: "getBundlesByEmail" + responses: + 200: + description: "Get bundles for this user." + schema: + $ref: '#/definitions/BundleList' + 404: + description: "No bundle found for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/purchases/{email}": + get: + description: "Get list of all purchases." + produces: + - application/json + - text/plain + operationId: "getPurchaseHistoryByEmail" + responses: + 200: + description: "List of Purchase Records." + schema: + $ref: '#/definitions/PurchaseRecordList' + 403: + description: "Not allowed to charge this source" + 404: + description: "No Purchase Records found for the user." + 503: + description: "Service Unavailable" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + "/refund/{email}": + put: + description: "Full refund of a purchase." + produces: + - application/json + - text/plain + operationId: "refundPurchaseByEmail" + responses: + 200: + description: "Purchase is refunded." + schema: + type: object + 403: + description: "Forbidden to refund this Purchase" + 404: + description: "Purchase record not found" + 502: + description: "Failed to refund purchase" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + - name: purchaseRecordId + in: query + description: "The record id of the purchase to be refunded" + required: true + type: string + - name: reason + in: query + description: "The reason for refund" + required: true + type: string + "/notify/{email}": + put: + description: "Send notification to a customer." + produces: + - application/json + - text/plain + operationId: "sendNotificationByEmail" + responses: + 200: + description: "Sent notification." + 404: + description: "Subscriber record not found" + 502: + description: "Failed to send notification" + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string + - name: title + in: query + description: "The title for the notification" + required: true + type: string + - name: message + in: query + description: "The notification message" + required: true + type: string + "/plans": + get: + description: "Get plan details" + produces: + - application/json + - text/plain + operationId: "getPlan" + responses: + 200: + description: "Plan details is returned" + schema: + $ref: '#/definitions/Plan' + 404: + description: "No such plan" + parameters: + - name: planId + in: path + description: "Name of plan to get" + required: true + type: string + security: + - auth0_jwt: [] + post: + description: "Create a plan" + produces: + - application/json + - text/plain + operationId: "createPlan" + responses: + 201: + description: "Successfully purchased the plan." + schema: + $ref: '#/definitions/Plan' + 400: + description: "Failed to create the plan" + parameters: + - name: plan + in: body + description: Plan details + schema: + $ref: '#/definitions/Plan' + security: + - auth0_jwt: [] + delete: + description: "Removes a plan" + produces: + - application/json + - text/plain + operationId: "deletePlan" + responses: + 200: + description: "Plan is removed" + schema: + $ref: '#/definitions/Plan' + 400: + description: "Failed to remove plan" + 404: + description: "No such plan" + parameters: + - name: planId + in: path + description: "The name of the plan to remove" + required: true + type: string + security: + - auth0_jwt: [] + "/profiles/{email}/state": + get: + description: "Get state of the user." + produces: + - application/json + operationId: "getCustomerState" + responses: + 200: + description: "Successfully retrieved the state." + schema: + $ref: '#/definitions/SubscriberState' + 404: + description: "No state information available for this user." + security: + - auth0_jwt: [] + parameters: + - name: email + in: path + description: "The email of the user" + required: true + type: string +definitions: + Profile: + type: object + properties: + name: + type: string + address: + type: string + postCode: + type: string + city: + type: string + country: + type: string + email: + type: string + format: email + referralId: + type: string + required: + - email + SubscriptionList: + type: array + items: + $ref: '#/definitions/Subscription' + Subscription: + type: object + properties: + msisdn: + description: "Mobile number for this subscription" + type: string + alias: + description: "Human readable optional alias for this subscription" + type: string + BundleList: + type: array + items: + $ref: '#/definitions/Bundle' + Bundle: + type: object + properties: + id: + description: "Bundle ID" + type: string + balance: + description: "Balance units in this bundle" + type: integer + format: int64 + PurchaseRecordList: + type: array + items: + $ref: '#/definitions/PurchaseRecord' + PurchaseRecord: + type: object + properties: + id: + description: "Purchase Record ID" + type: string + msisdn: + description: "Deprecated: The MSISDN for which the purchase was made." + type: string + timestamp: + description: "The time stamp of the purchase" + type: integer + format: int64 + product: + $ref: '#/definitions/Product' + required: + - timestamp + - product + - end + Product: + type: object + properties: + sku: + description: "A unique Id representing a SKU" + type: string + price: + $ref: '#/definitions/Price' + properties: + type: object + presentation: + type: object + required: + - sku + - price + Price: + type: object + properties: + amount: + description: "A positive integer in the smallest currency unit" + type: integer + minimum: 0 + currency: + description: "ISO 4217 currency code (three letter alphabetic code)" + type: string + required: + - amount + - currency + Plan: + type: object + properties: + name: + description: "An unique name representing the plan" + type: string + price: + $ref: '#/definitions/Price' + interval: + description: "The recurring period for the plan" + type: string + enum: [ day, week, month, year ] + intervalCount: + description: "Number of intervals in a period" + type: integer + default: 1 + minimum: 1 + properties: + description: "Free form key/value pairs" + type: object + additionalProperties: true + presentation: + description: "Pretty print version of plan" + type: object + additionalProperties: true + required: + - name + - price + - interval + PlanList: + type: array + items: + $ref: '#/definitions/Plan' + SubscriberState: + type: object + properties: + id: + description: "User Id" + type: string + status: + description: "Current status of the customer" + type: string + modifiedTimestamp: + description: "Last modified time for the status (Unix timestamp)" + type: integer + format: int64 + required: + - id + - status + - modifiedTimestamp + ScanInformationList: + type: array + items: + $ref: '#/definitions/ScanInformation' + ScanInformation: + type: object + properties: + scanId: + description: "New scan Id for eKYC" + type: string + countryCode: + description: "The 3 letter country code (or global) for the scan " + type: string + status: + description: "The status of the scan" + type: string + scanResult: + description: "The result from the vendor" + type: object + required: + - scanId + - status +securityDefinitions: + auth0_jwt: + authorizationUrl: "https://redotter-admin-dev.eu.auth0.com/authorize" + flow: "implicit" + type: "oauth2" + x-google-issuer: "https://redotter-admin-dev.eu.auth0.com/" + x-google-jwks_uri: "https://redotter-admin-dev.eu.auth0.com/.well-known/jwks.json" + x-google-audiences: "http://google_api" diff --git a/prime/infra/prod/prime-webhooks.yaml b/prime/infra/prod/prime-webhooks.yaml new file mode 100644 index 000000000..fc0dc690b --- /dev/null +++ b/prime/infra/prod/prime-webhooks.yaml @@ -0,0 +1,48 @@ +swagger: "2.0" +info: + title: "Prime 3rd party API" + description: "Prime endpoints for use by external services." + version: "1.0.0" +host: "alvin-api.oya.world" +x-google-endpoints: + - name: "alvin-api.oya.world" + allowCors: true +schemes: + - "https" +paths: + "/stripe/event": + post: + description: "Endpoint for event reports from Stripe." + produces: + - application/json + operationId: "handleEvent" + responses: + 200: + description: "Event report processed successfully." + 400: + description: "Failed to process event report." + 500: + description: "Unexpected error." + security: + - api_key: [] + "/ekyc/callback": + post: + description: "Endpoint for event reports from eKYC." + produces: + - application/json + operationId: "handleCallback" + responses: + 200: + description: "Event report processed successfully." + 400: + description: "Failed to process event report." + 500: + description: "Unexpected error." + security: + - api_key: [] +securityDefinitions: + # This section configures basic authentication with an API key. + api_key: + type: "apiKey" + name: "key" + in: "query" diff --git a/prime/infra/prod/prime.yaml b/prime/infra/prod/prime.yaml deleted file mode 100644 index 070bcd225..000000000 --- a/prime/infra/prod/prime.yaml +++ /dev/null @@ -1,202 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: prime-service - labels: - app: prime - tier: backend -spec: - type: LoadBalancer - loadBalancerIP: 35.195.49.238 - ports: - - name: grpc - port: 443 - targetPort: 8443 - protocol: TCP - selector: - app: prime - tier: backend ---- -apiVersion: v1 -kind: Service -metadata: - name: prime-api - labels: - app: prime - tier: backend -spec: - type: LoadBalancer - loadBalancerIP: 35.233.36.235 - ports: - - name: https - port: 443 - protocol: TCP - selector: - app: prime - tier: backend ---- -apiVersion: v1 -kind: Service -metadata: - name: prime-metrics - labels: - app: prime - tier: backend -spec: - type: LoadBalancer - loadBalancerIP: 35.240.23.167 - ports: - - name: grpc - port: 443 - targetPort: 9443 - protocol: TCP - selector: - app: prime - tier: backend ---- -apiVersion: v1 -kind: Service -metadata: - name: pseudonym-server-service - labels: - app: prime - tier: backend -spec: - ports: - - protocol: TCP - port: 80 - targetPort: 8080 - selector: - app: prime - tier: backend ---- -apiVersion: extensions/v1beta1 -kind: Deployment -metadata: - name: prime - labels: - app: prime - tier: backend -spec: - replicas: 1 - template: - metadata: - labels: - app: prime - tier: backend - annotations: - prometheus.io/scrape: 'true' - prometheus.io/path: '/prometheus-metrics' - prometheus.io/port: '8081' - spec: - initContainers: - - name: "init-downloader" - image: "google/cloud-sdk:latest" - command: ['sh', '-c', 'gsutil cp gs://prime-files/prod/*.* /config-data/'] - volumeMounts: - - name: config-data - mountPath: /config-data/ - containers: - - name: ocs-esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - args: [ - "--http2_port=9000", - "--ssl_port=8443", - "--status_port=8090", - "--service=ocs.ostelco.org", - "--rollout_strategy=managed", - "--backend=grpc://127.0.0.1:8082" - ] - ports: - - containerPort: 9000 - - containerPort: 8443 - volumeMounts: - - mountPath: /etc/nginx/ssl - name: ocs-ostelco-ssl - readOnly: true - - name: api-esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - args: [ - "--http2_port=9002", - "--ssl_port", "443", - "--status_port=8092", - "--service=api.ostelco.org", - "--rollout_strategy=managed", - "--backend=127.0.0.1:8080" - ] - ports: - - containerPort: 9002 - - containerPort: 443 - volumeMounts: - - mountPath: /etc/nginx/ssl - name: api-ostelco-ssl - readOnly: true - - name: metrics-esp - image: gcr.io/endpoints-release/endpoints-runtime:1 - args: [ - "--http2_port=9004", - "--ssl_port=9443", - "--status_port=8094", - "--service=metrics.ostelco.org", - "--rollout_strategy=managed", - "--backend=grpc://127.0.0.1:8083" - ] - ports: - - containerPort: 9004 - - containerPort: 9443 - volumeMounts: - - mountPath: /etc/nginx/ssl - name: metrics-ostelco-ssl - readOnly: true - - name: prime - image: eu.gcr.io/pantel-2decb/prime:PRIME_VERSION - imagePullPolicy: Always - env: - - name: SLACK_CHANNEL - value: prime-alerts - - name: SLACK_WEBHOOK_URI - valueFrom: - secretKeyRef: - name: slack-secrets - key: slackWebHookUri - - name: NEO4J_HOST - value: neo4j - - name: FIREBASE_ROOT_PATH - value: v2 - - name: DATA_TRAFFIC_TOPIC - value: data-traffic - - name: PURCHASE_INFO_TOPIC - value: purchase-info - - name: ACTIVE_USERS_TOPIC - value: active-users - - name: STRIPE_API_KEY - valueFrom: - secretKeyRef: - name: stripe-secrets - key: stripeApiKey - volumeMounts: - - name: secret-config - mountPath: "/secret" - - name: config-data - mountPath: "/config-data" - readOnly: true - ports: - - containerPort: 8080 - - containerPort: 8081 - - containerPort: 8082 - - containerPort: 8083 - volumes: - - name: secret-config - secret: - secretName: pantel-prod.json - - name: api-ostelco-ssl - secret: - secretName: api-ostelco-ssl - - name: ocs-ostelco-ssl - secret: - secretName: ocs-ostelco-ssl - - name: metrics-ostelco-ssl - secret: - secretName: metrics-ostelco-ssl - - name: config-data - emptyDir: {} diff --git a/prime/infra/raw_purchases_schema.ddl b/prime/infra/raw_purchases_schema.ddl index 6172c84d7..38627290c 100644 --- a/prime/infra/raw_purchases_schema.ddl +++ b/prime/infra/raw_purchases_schema.ddl @@ -1,10 +1,10 @@ - CREATE TABLE purchases.raw_purchases - ( - id STRING NOT NULL, - subscriberId STRING NOT NULL, - timestamp INT64 NOT NULL, - status STRING NOT NULL, - product STRUCT< +CREATE TABLE purchases.raw_purchases +( + id STRING NOT NULL, + subscriberId STRING NOT NULL, + timestamp INT64 NOT NULL, + status STRING NOT NULL, + product STRUCT< sku STRING NOT NULL, price STRUCT< amount INT64 NOT NULL, @@ -18,17 +18,22 @@ key STRING NOT NULL, value STRING NOT NULL > > - > NOT NULL + > NOT NULL, + refund STRUCT< + id STRING NOT NULL, + reason STRING NOT NULL, + timestamp INT64 NOT NULL + > ) PARTITION BY DATE(_PARTITIONTIME) - CREATE TABLE purchases_dev.raw_purchases - ( - id STRING NOT NULL, - subscriberId STRING NOT NULL, - timestamp INT64 NOT NULL, - status STRING NOT NULL, - product STRUCT< +CREATE TABLE purchases_dev.raw_purchases +( + id STRING NOT NULL, + subscriberId STRING NOT NULL, + timestamp INT64 NOT NULL, + status STRING NOT NULL, + product STRUCT< sku STRING NOT NULL, price STRUCT< amount INT64 NOT NULL, @@ -42,6 +47,11 @@ PARTITION BY DATE(_PARTITIONTIME) key STRING NOT NULL, value STRING NOT NULL > > - > NOT NULL + > NOT NULL, + refund STRUCT< + id STRING NOT NULL, + reason STRING NOT NULL, + timestamp INT64 NOT NULL + > ) PARTITION BY DATE(_PARTITIONTIME) \ No newline at end of file diff --git a/prime/periodic-provisioning-task.yaml b/prime/periodic-provisioning-task.yaml new file mode 100644 index 000000000..fca287f5b --- /dev/null +++ b/prime/periodic-provisioning-task.yaml @@ -0,0 +1,24 @@ +# +# To start this job, do +# kubectl create -f ./cronjob.yaml +# https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/ +# TODO: sync with prime.yaml + +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: preallocate-sim-profiles +spec: + schedule: "*/1 * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: preallocate-sim-profiles + image: gcr.io/gcp-runtimes/ubuntu_18_0_4 + args: + - /bin/sh + - -c + - date; echo Triggering sim preallocation; curl -vvv -sS -X POST http://prime-dwadmin-service:8081/tasks/preallocate_sim_profiles + restartPolicy: OnFailure diff --git a/prime/script/check_repo.sh b/prime/script/check_repo.sh deleted file mode 100755 index babebe7db..000000000 --- a/prime/script/check_repo.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -# This script checks if current branch is master - -if [ master != "$1" ]; then - (>&2 echo "Aborting. '$1' is not 'master' branch.") - exit 1; -fi -exit 0; \ No newline at end of file diff --git a/prime/script/deploy-dev-direct.sh b/prime/script/deploy-dev-direct.sh deleted file mode 100755 index aaef0410f..000000000 --- a/prime/script/deploy-dev-direct.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [ ! -f prime/script/deploy.sh ]; then - (>&2 echo "Run this script from project root dir (ostelco-core)") - exit 1 -fi - -kubectl config use-context $(kubectl config get-contexts --output name | grep dev-cluster) - -PROJECT_ID="$(gcloud config get-value project -q)" -PRIME_VERSION="$(gradle prime:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" -SHORT_SHA="$(git log -1 --pretty=format:%h)" -TAG="${PRIME_VERSION}-${SHORT_SHA}-dev" - -echo PROJECT_ID=${PROJECT_ID} -echo PRIME_VERSION=${PRIME_VERSION} -echo SHORT_SHA=${SHORT_SHA} -echo TAG=${TAG} - - -gradle prime:clean prime:build -docker build -t eu.gcr.io/${PROJECT_ID}/prime:${TAG} prime -docker push eu.gcr.io/${PROJECT_ID}/prime:${TAG} - -echo "Deploying prime to GKE" - -sed -e s/PRIME_VERSION/${TAG}/g prime/infra/dev/prime.yaml | kubectl apply -f - \ No newline at end of file diff --git a/prime/script/deploy-dev.sh b/prime/script/deploy-dev.sh deleted file mode 100755 index 05d532644..000000000 --- a/prime/script/deploy-dev.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [ ! -f prime/script/deploy.sh ]; then - (>&2 echo "Run this script from project root dir (ostelco-core)") - exit 1 -fi - -PROJECT_ID="$(gcloud config get-value project -q)" -SHORT_SHA="$(git log -1 --pretty=format:%h)" - -echo PROJECT_ID=${PROJECT_ID} -echo SHORT_SHA=${SHORT_SHA} - -echo "Deploying prime to GKE" - -gcloud container builds submit \ - --config prime/cloudbuild.dev.yaml \ - --substitutions SHORT_SHA=${SHORT_SHA} . \ No newline at end of file diff --git a/prime/script/deploy-direct.sh b/prime/script/deploy-direct.sh deleted file mode 100755 index 6ec9cb82c..000000000 --- a/prime/script/deploy-direct.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [ ! -f prime/script/deploy.sh ]; then - (>&2 echo "Run this script from project root dir (ostelco-core)") - exit 1 -fi - -CHECK_REPO="prime/script/check_repo.sh" - -if [ ! -f ${CHECK_REPO} ]; then - (>&2 echo "Missing file - $CHECK_REPO") - exit 1 -fi - -kubectl config use-context $(kubectl config get-contexts --output name | grep private-cluster) - -BRANCH_NAME=$(git branch | grep \* | cut -d ' ' -f2) -echo BRANCH_NAME=${BRANCH_NAME} -${CHECK_REPO} ${BRANCH_NAME} - -PROJECT_ID="$(gcloud config get-value project -q)" -PRIME_VERSION="$(gradle prime:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" -SHORT_SHA="$(git log -1 --pretty=format:%h)" -TAG="${PRIME_VERSION}-${SHORT_SHA}" - -echo PROJECT_ID=${PROJECT_ID} -echo PRIME_VERSION=${PRIME_VERSION} -echo SHORT_SHA=${SHORT_SHA} -echo TAG=${TAG} - - -gradle prime:clean prime:build -docker build -t eu.gcr.io/${PROJECT_ID}/prime:${TAG} prime -docker push eu.gcr.io/${PROJECT_ID}/prime:${TAG} - -echo "Deploying prime to GKE" - -sed -e s/PRIME_VERSION/${TAG}/g prime/infra/prod/prime.yaml | kubectl apply -f - \ No newline at end of file diff --git a/prime/script/deploy.sh b/prime/script/deploy.sh deleted file mode 100755 index e1a64f080..000000000 --- a/prime/script/deploy.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [ ! -f prime/script/deploy.sh ]; then - (>&2 echo "Run this script from project root dir (ostelco-core)") - exit 1 -fi - -CHECK_REPO="prime/script/check_repo.sh" - -if [ ! -f ${CHECK_REPO} ]; then - (>&2 echo "Missing file - $CHECK_REPO") - exit 1 -fi - -PROJECT_ID="$(gcloud config get-value project -q)" -PRIME_VERSION="$(gradle prime:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" -BRANCH_NAME=$(git branch | grep \* | cut -d ' ' -f2) - -echo PROJECT_ID=${PROJECT_ID} -echo PRIME_VERSION=${PRIME_VERSION} -echo BRANCH_NAME=${BRANCH_NAME} - -${CHECK_REPO} ${BRANCH_NAME} - -echo "Deploying prime to GKE" - -gcloud container builds submit \ - --config prime/cloudbuild.yaml \ - --substitutions TAG_NAME=${PRIME_VERSION},BRANCH_NAME=${BRANCH_NAME} . \ No newline at end of file diff --git a/prime/script/deploy_api.sh b/prime/script/deploy_api.sh deleted file mode 100644 index 7ed917c6b..000000000 --- a/prime/script/deploy_api.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash -# Copyright 2017 Google Inc. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Original: https://github.com/GoogleCloudPlatform/endpoints-quickstart/blob/master/scripts/deploy_api.sh - -set -euo pipefail - -source util.sh - -main() { - # Get our working project, or exit if it's not set. - local project_id=$(get_project_id) - if [[ -z "$project_id" ]]; then - exit 1 - fi - local temp_file=$(mktemp) - export TEMP_FILE="${temp_file}.yaml" - mv "$temp_file" "$TEMP_FILE" - # Because the included API is a template, we have to do some string - # substitution before we can deploy it. Sed does this nicely. - < "$API_FILE" sed -E "s/YOUR-PROJECT-ID/${project_id}/g" > "$TEMP_FILE" - echo "Deploying $API_FILE..." - echo "gcloud endpoints services deploy $API_FILE" - gcloud endpoints services deploy "$TEMP_FILE" -} - -cleanup() { - rm "$TEMP_FILE" -} - -# Defaults. -API_FILE="../openapi/prime-openapi.yaml" - -if [[ "$#" == 0 ]]; then - : # Use defaults. -elif [[ "$#" == 1 ]]; then - API_FILE="$1" -else - echo "Wrong number of arguments specified." - echo "Usage: deploy_api.sh [api-file]" - exit 1 -fi - -# Cleanup our temporary files even if our deployment fails. -trap cleanup EXIT - -main "$@" diff --git a/prime/script/helm-deploy-dev-direct.sh b/prime/script/helm-deploy-dev-direct.sh new file mode 100755 index 000000000..48217ee55 --- /dev/null +++ b/prime/script/helm-deploy-dev-direct.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +## +## Deploy prime directly from workstation +## + + +DEPENDENCIES="./gradlew docker sed grep tr awk gcloud helm" +for DEP in $DEPENDENCIES; do + if [[ -z "$(which $DEP)" ]] ; then + echo "$0 ERROR: Missing dependency $DEP" + exit 1 + fi +done + +# On error fail. +set -e + +# Check that the script ss run from project root +# (figure that out by looking for itself :-) +if [[ ! -f prime/script/helm-deploy-dev-direct.sh ]] ; then + (>&2 echo "Run this script from project root dir (ostelco-core)") + exit 1 +fi + +# +# Use the kubectl context containing the dev-cluster +# +K8S_CONTEXT="$(kubectl config get-contexts --output name | grep pi-ostelco-dev)" +kubectl config use-context ${K8S_CONTEXT} + +# +# Get the GCP project id by asking gcloud +# +GCP_PROJECT_ID="$(gcloud config get-value project -q)" + +# +# Get the version, sha and tag tat we'll use to +# identify the docker image we're about to deploy. +# +PRIME_VERSION="$(./gradlew prime:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" +SHORT_SHA="$(git log -1 --pretty=format:%h)" +TAG="${PRIME_VERSION}-${SHORT_SHA}-dev" + +# +# Report what the variables we're using are +# +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} +echo PRIME_VERSION=${PRIME_VERSION} +echo SHORT_SHA=${SHORT_SHA} +echo TAG=${TAG} + + +# +# Build the prime subproject +# +./gradlew prime:clean prime:build + +# +# Build tag and push the docker image +# +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/prime:${TAG} prime +docker push eu.gcr.io/${GCP_PROJECT_ID}/prime:${TAG} + +HELM_RELEASE_NAME="prime-direct" +HELM_CHART="ostelco/prime" +HELM_CHART_VERSION="0.6.1" +HELM_VALUES_FILE="prime/infra/prime-direct-values.yaml" + +# +# Then deploy using kubectl. +# +echo "Deploying prime to GKE" + +helm repo add ostelco https://storage.googleapis.com/pi-ostelco-helm-charts-repo/ +helm repo update +helm upgrade ${HELM_RELEASE_NAME} ${HELM_CHART} --version ${HELM_CHART_VERSION} --install -f ${HELM_VALUES_FILE} --set prime.tag=${TAG} diff --git a/prime/script/start.sh b/prime/script/start.sh index 88b5a5558..3bf55bf1f 100755 --- a/prime/script/start.sh +++ b/prime/script/start.sh @@ -1,5 +1,11 @@ #!/bin/bash +# XXX REMOVE BEFORE FLIGHT (only for debugging +# idemia backchannel) + +nohup tcpdump -s 1500 portrange 8000-9000 -w dumpfile.pcap > dump.out & + + # Start app exec java \ -Dfile.encoding=UTF-8 \ diff --git a/prime/script/util.sh b/prime/script/util.sh deleted file mode 100644 index d7c653721..000000000 --- a/prime/script/util.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -# Copyright 2017 Google Inc. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Make Bash a little less error-prone. -set -euo pipefail - -get_latest_config_id() { - # Given a service name, this returns the most recent deployment of that - # API. - service_name="$1" - gcloud endpoints configs list \ - --service="$service_name" \ - --sort-by="~config_id" --limit=1 --format="value(CONFIG_ID)" \ - | tr -d '[:space:]' -} - -get_project_id() { - # Find the project ID first by DEVSHELL_PROJECT_ID (in Cloud Shell) - # and then by querying the gcloud default project. - local project="${DEVSHELL_PROJECT_ID:-}" - if [[ -z "$project" ]]; then - project=$(gcloud config get-value project 2> /dev/null) - fi - if [[ -z "$project" ]]; then - >&2 echo "No default project was found, and DEVSHELL_PROJECT_ID is not set." - >&2 echo "Please use the Cloud Shell or set your default project by typing:" - >&2 echo "gcloud config set project YOUR-PROJECT-NAME" - fi - echo "$project" -} diff --git a/prime/script/wait.sh b/prime/script/wait.sh index b3bfd9d1e..0f8ca83b4 100755 --- a/prime/script/wait.sh +++ b/prime/script/wait.sh @@ -33,18 +33,47 @@ done echo "Pubsub emulator launched" -echo "Creating topics and subscriptions...." +echo "Creating topics...." -curl -X PUT pubsub-emulator:8085/v1/projects/pantel-2decb/topics/data-traffic -curl -X PUT pubsub-emulator:8085/v1/projects/pantel-2decb/topics/pseudo-traffic -curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/pantel-2decb/topics/data-traffic","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/pantel-2decb/subscriptions/test-pseudo -curl -X PUT pubsub-emulator:8085/v1/projects/pantel-2decb/topics/purchase-info -curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/pantel-2decb/topics/purchase-info","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/pantel-2decb/subscriptions/purchase-info-sub +# For Analytics +curl -X PUT pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/topics/active-users +curl -X PUT pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/topics/data-traffic +curl -X PUT pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/topics/purchase-info -echo "Done creating topics and subscriptions" +# For Stripe +curl -X PUT pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/topics/stripe-event + +# For OCS API +curl -X PUT pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/topics/ocs-ccr +curl -X PUT pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/topics/ocs-cca +curl -X PUT pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/topics/ocs-activate + +echo "Done creating topics" + +echo "Creating subscriptions...." + +# For Analytics +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/data-traffic","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/test-pseudo +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/purchase-info","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/purchase-info-sub + +# For Stripe +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/stripe-event","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/stripe-event-store-sub +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/stripe-event","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/stripe-event-report-sub + +# For OCS API +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/ocs-ccr","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/ocs-ccr-sub +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/ocs-cca","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/ocsgw-cca-sub +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/ocs-activate","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/ocsgw-activate-sub + +echo "Done creating subscriptions" # Forward the local port 9090 to datastore-emulator:8081 -if [ -z $(type socat) ]; then echo "socat not installed."; exit 1; fi +if ! hash socat 2>/dev/null +then + echo "socat not installed." + exit 1 +fi + socat TCP-LISTEN:9090,fork TCP:datastore-emulator:8081 & # Start app diff --git a/prime/src/integration-tests/kotlin/org/ostelco/prime/TestPrimeConfig.kt b/prime/src/integration-tests/kotlin/org/ostelco/prime/TestPrimeConfig.kt index f90e11ae6..51de2dd6a 100644 --- a/prime/src/integration-tests/kotlin/org/ostelco/prime/TestPrimeConfig.kt +++ b/prime/src/integration-tests/kotlin/org/ostelco/prime/TestPrimeConfig.kt @@ -45,7 +45,7 @@ class TestPrimeConfig { @JvmStatic @BeforeClass fun beforeClass() { - SUPPORT.before() + SUPPORT.before() } @JvmStatic diff --git a/prime/src/integration-tests/kotlin/org/ostelco/prime/ocs/OcsTest.kt b/prime/src/integration-tests/kotlin/org/ostelco/prime/ocs/OcsTest.kt deleted file mode 100644 index ab6e08749..000000000 --- a/prime/src/integration-tests/kotlin/org/ostelco/prime/ocs/OcsTest.kt +++ /dev/null @@ -1,279 +0,0 @@ -package org.ostelco.prime.ocs - -import com.palantir.docker.compose.DockerComposeRule -import com.palantir.docker.compose.connection.waiting.HealthChecks -import io.grpc.ManagedChannelBuilder -import io.grpc.stub.StreamObserver -import org.apache.commons.lang3.RandomStringUtils -import org.joda.time.Duration -import org.junit.AfterClass -import org.junit.Assert.assertEquals -import org.junit.BeforeClass -import org.junit.ClassRule -import org.junit.Test -import org.ostelco.ocs.api.ActivateRequest -import org.ostelco.ocs.api.ActivateResponse -import org.ostelco.ocs.api.CreditControlAnswerInfo -import org.ostelco.ocs.api.CreditControlRequestInfo -import org.ostelco.ocs.api.CreditControlRequestType.INITIAL_REQUEST -import org.ostelco.ocs.api.MultipleServiceCreditControl -import org.ostelco.ocs.api.OcsServiceGrpc -import org.ostelco.ocs.api.OcsServiceGrpc.OcsServiceStub -import org.ostelco.ocs.api.ServiceUnit -import org.ostelco.prime.consumption.OcsGrpcServer -import org.ostelco.prime.consumption.OcsService -import org.ostelco.prime.disruptor.EventProducerImpl -import org.ostelco.prime.disruptor.OcsDisruptor -import org.ostelco.prime.getLogger -import org.ostelco.prime.storage.firebase.initFirebaseConfigRegistry -import org.ostelco.prime.storage.graph.Config -import org.ostelco.prime.storage.graph.ConfigRegistry -import org.ostelco.prime.storage.graph.Neo4jClient -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * - * - * This class tests the packet gateway's perspective of talking to - * the OCS over the gRPC-generated wire protocol. - */ -class OcsTest { - - private val logger by getLogger() - - abstract class AbstractObserver : StreamObserver { - - private val logger by getLogger() - - override fun onError(t: Throwable) { - // Ignore errors - } - - override fun onCompleted() { - logger.info("Completed") - } - } - - private fun newDefaultCreditControlRequestInfo(): CreditControlRequestInfo { - logger.info("Req Id: {}", REQUEST_ID) - - val mscc = MultipleServiceCreditControl.newBuilder() - .setRequested(ServiceUnit - .newBuilder() - .setTotalOctets(BYTES)) - return CreditControlRequestInfo.newBuilder() - .setType(INITIAL_REQUEST) - .setMsisdn(MSISDN) - .setRequestId(REQUEST_ID) - .addMscc(mscc) - .build() - } - - /** - * This whole test case tests the packet gateway talking to the OCS over - * the gRPC interface. - */ - @Test - fun testFetchDataRequest() { - - // If this latch reaches zero, then things went well. - val cdl = CountDownLatch(1) - - // Simulate being the OCS receiving a packet containing - // information about a data bucket containing a number - // of bytes for some MSISDN. - val requests = ocsServiceStub.creditControlRequest( - object : AbstractObserver() { - override fun onNext(response: CreditControlAnswerInfo) { - logger.info("Received answer for {}", - response.msisdn) - assertEquals(MSISDN, response.msisdn) - assertEquals(REQUEST_ID, response.requestId) - cdl.countDown() - } - }) - - // Simulate packet gateway requesting a new bucket of data by - // injecting a packet of data into the "onNext" method declared - // above. - requests.onNext(newDefaultCreditControlRequestInfo()) - - // Wait for response (max ten seconds) and the pass the test only - // if a response was actually generated (and cdl counted down to zero). - cdl.await(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) - - requests.onCompleted() - - assertEquals(0, cdl.count) - } - - /** - * Simulate sending a request to activate a subscription. - * - * @throws InterruptedException - */ - @Test - fun testActivateMsisdn() { - - val cdl = CountDownLatch(2) - - val streamObserver = object : AbstractObserver() { - override fun onNext(response: ActivateResponse) { - if (!response.msisdn.isEmpty()) { - logger.info("Activate {}", response.msisdn) - assertEquals(MSISDN, response.msisdn) - } - cdl.countDown() - } - } - - // Get the default (singelton) instance of an activation request. - val activateRequest = ActivateRequest.getDefaultInstance() - - // Send it over the wire, but also send stream observer that will be - // invoked when a reply comes back. - ocsServiceStub.activate(activateRequest, streamObserver) - - // Wait for a second to let things get through, then move on. - Thread.sleep(ONE_SECOND_IN_MILLISECONDS) - - // Send a report using the producer to the pipeline that will - // inject a PrimeEvent that will top up the data bundle balance. - producer.topupDataBundleBalanceEvent( - requestId = TOPUP_REQ_ID, - bundleId = BUNDLE_ID, - bytes = NO_OF_BYTES_TO_ADD) - - // Now wait, again, for the latch to reach zero, and fail the test - // ff it hasn't. - cdl.await(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) - assertEquals(0, cdl.count) - } - - companion object { - - /** - * The port on which the gRPC service will be receiving incoming - * connections. - */ - private const val PORT = 8082 - - /** - * The phone numbe for which we're faking data consumption during this test. - */ - private const val MSISDN = "4790300017" - - private const val BUNDLE_ID = "foo@bar.com" - - private const val TOPUP_REQ_ID = "req-id" - - // Default chunk of byte used in various test cases - private const val BYTES: Long = 100 - - // Request ID used by OCS gateway to correlate responses with requests - private val REQUEST_ID = RandomStringUtils.randomAlphanumeric(22) - - private const val NO_OF_BYTES_TO_ADD = 10000L - - private const val TIMEOUT_IN_SECONDS = 10L - - private const val ONE_SECOND_IN_MILLISECONDS = 1000L - - /** - * This is the "disruptor" (processing engine) that will process - * PrimeEvent instances and send them through a sequence of operations that - * will update the balance of bytes for a particular subscription. - * - * - * Disruptor also provides RingBuffer, which is used by Producer - */ - private lateinit var disruptor: OcsDisruptor - - /** - * - */ - private lateinit var producer: EventProducerImpl - - /** - * The gRPC service that will produce incoming events from the - * simulated packet gateway, contains an [OcsSubscriberService] instance bound - * to a particular port (in our case 8082). - */ - private lateinit var ocsServer: OcsGrpcServer - - /** - * The "sub" that will mediate access to the GRPC channel, - * providing an API to send / receive data to/from it. - */ - private lateinit var ocsServiceStub: OcsServiceStub - - @ClassRule - @JvmField - var docker: DockerComposeRule = DockerComposeRule.builder() - .file("src/integration-tests/resources/docker-compose.yaml") - .waitingForService("neo4j", HealthChecks.toHaveAllPortsOpen()) - .waitingForService("neo4j", - HealthChecks.toRespond2xxOverHttp(7474) { - port -> port.inFormat("http://\$HOST:\$EXTERNAL_PORT/browser") - }, - Duration.standardSeconds(40L)) - .build() - - @BeforeClass - @JvmStatic - fun setUp() { - ConfigRegistry.config = Config().apply { - this.host = "0.0.0.0" - this.protocol = "bolt" - } - initFirebaseConfigRegistry() - - Neo4jClient.start() - - // Set up processing pipeline - disruptor = OcsDisruptor() - producer = EventProducerImpl(disruptor.disruptor.ringBuffer) - - // Set up the gRPC server at a particular port with a particular - // service, that is connected to the processing pipeline. - val ocsService = OcsService(producer) - ocsServer = OcsGrpcServer(PORT, ocsService.ocsGrpcService) - - val ocsState = OcsState() - ocsState.msisdnToBundleIdMap[MSISDN] = BUNDLE_ID - ocsState.bundleIdToMsisdnMap[BUNDLE_ID] = mutableSetOf(MSISDN) - - ocsState.addDataBundleBytesForMsisdn(MSISDN, NO_OF_BYTES_TO_ADD) - - // Events flow: - // Producer:(OcsService, Subscriber) - // -> Handler:(OcsState) - // -> Handler:(OcsService, Subscriber) - disruptor.disruptor.handleEventsWith(ocsState).then(ocsService.eventHandler) - - // start disruptor and ocs services. - disruptor.start() - ocsServer.start() - - // Set up a channel to be used to communicate as an OCS instance, to an - // Prime instance. - val channel = ManagedChannelBuilder - .forTarget("0.0.0.0:$PORT") - .usePlaintext() // disable encryption for testing - .build() - - // Initialize the stub that will be used to actually - // communicate from the client emulating being the OCS. - ocsServiceStub = OcsServiceGrpc.newStub(channel) - } - - @AfterClass - @JvmStatic - fun tearDown() { - disruptor.stop() - ocsServer.forceStop() - Neo4jClient.stop() - } - } -} diff --git a/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbConfigSetup.kt b/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbConfigSetup.kt index be9b92715..62ef40f44 100644 --- a/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbConfigSetup.kt +++ b/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbConfigSetup.kt @@ -2,6 +2,6 @@ package org.ostelco.prime.storage.firebase fun initFirebaseConfigRegistry() { FirebaseConfigRegistry.firebaseConfig = FirebaseConfig( - configFile = "config/pantel-prod.json", + configFile = "config/prime-service-account.json", rootPath = "test") } \ No newline at end of file diff --git a/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbStorageTest.kt b/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbStorageTest.kt index b0f85e96b..1dbc54f23 100644 --- a/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbStorageTest.kt +++ b/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/firebase/FbStorageTest.kt @@ -36,22 +36,22 @@ class FbStorageTest { applicationID = applicationId, tokenType = tokenType) - assertTrue(storage.addNotificationToken(MSISDN, applicationToken)) - val reply = storage.getNotificationToken(MSISDN, applicationId) + assertTrue(storage.addNotificationToken(CUSTOMER_ID, applicationToken)) + val reply = storage.getNotificationToken(CUSTOMER_ID, applicationId) Assert.assertNotNull(reply) Assert.assertEquals(reply?.token, token) Assert.assertEquals(reply?.applicationID, applicationId) Assert.assertEquals(reply?.tokenType, tokenType) - Assert.assertEquals(storage.getNotificationTokens(MSISDN).size, 1) + Assert.assertEquals(storage.getNotificationTokens(CUSTOMER_ID).size, 1) - assertTrue(storage.removeNotificationToken(MSISDN, applicationId)) - Assert.assertEquals(storage.getNotificationTokens(MSISDN).size, 0) + assertTrue(storage.removeNotificationToken(CUSTOMER_ID, applicationId)) + Assert.assertEquals(storage.getNotificationTokens(CUSTOMER_ID).size, 0) } companion object { - private const val MSISDN = "4747116996" + private val CUSTOMER_ID = UUID.randomUUID().toString() private const val MILLIS_TO_WAIT_WHEN_STARTING_UP = 3000 } diff --git a/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/graph/Neo4jStorageTest.kt b/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/graph/Neo4jStorageTest.kt index 2dd16ae3d..7ece59e5b 100644 --- a/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/graph/Neo4jStorageTest.kt +++ b/prime/src/integration-tests/kotlin/org/ostelco/prime/storage/graph/Neo4jStorageTest.kt @@ -13,12 +13,10 @@ import org.junit.Before import org.junit.BeforeClass import org.junit.ClassRule import org.junit.Test -import org.mockito.Mockito.mock -import org.ostelco.prime.disruptor.EventProducer import org.ostelco.prime.model.Bundle +import org.ostelco.prime.model.Customer +import org.ostelco.prime.model.Identity import org.ostelco.prime.model.PurchaseRecord -import org.ostelco.prime.model.Subscriber -import org.ostelco.prime.ocs.OcsPrimeServiceSingleton import org.ostelco.prime.storage.GraphStore import org.ostelco.prime.storage.firebase.initFirebaseConfigRegistry import org.ostelco.prime.storage.graph.Products.DATA_TOPUP_3GB @@ -35,40 +33,47 @@ class Neo4jStorageTest { this.storage = Neo4jStore() sleep(MILLIS_TO_WAIT_WHEN_STARTING_UP.toLong()) - storage.removeSubscriber(EPHERMERAL_EMAIL) - storage.addSubscriber(Subscriber(EPHERMERAL_EMAIL, country = COUNTRY), referredBy = null) + storage.removeCustomer(IDENTITY) + storage.addCustomer(IDENTITY, Customer(contactEmail = EPHERMERAL_EMAIL, nickname = NAME), referredBy = null) .mapLeft { fail(it.message) } - storage.addSubscription(EPHERMERAL_EMAIL, MSISDN) + storage.addSubscription(IDENTITY, MSISDN) .mapLeft { fail(it.message) } } @After fun cleanUp() { - storage.removeSubscriber(EPHERMERAL_EMAIL) + storage.removeCustomer(IDENTITY) } @Test fun createReadDeleteSubscriber() { - assertNotNull(storage.getSubscriber(EPHERMERAL_EMAIL)) + assertNotNull(storage.getCustomer(IDENTITY)) } @Test fun setBalance() { - assertTrue(storage.updateBundle(Bundle(EPHERMERAL_EMAIL, RANDOM_NO_OF_BYTES_TO_USE_BY_REMAINING_MSISDN_TESTS)).isRight()) + val bundleId = storage.getBundles(IDENTITY).fold( + { + fail(it.message) + "" + }, + { it.first().id }) - storage.getBundles(EPHERMERAL_EMAIL).bimap( + assertTrue(storage.updateBundle(Bundle(bundleId, RANDOM_NO_OF_BYTES_TO_USE_BY_REMAINING_MSISDN_TESTS)).isRight()) + + storage.getBundles(IDENTITY).bimap( { fail(it.message) }, { bundles -> assertEquals(RANDOM_NO_OF_BYTES_TO_USE_BY_REMAINING_MSISDN_TESTS, - bundles.firstOrNull { it.id == EPHERMERAL_EMAIL }?.balance) + bundles.firstOrNull { it.id == bundleId }?.balance) }) - storage.updateBundle(Bundle(EPHERMERAL_EMAIL, 0)) - storage.getBundles(EPHERMERAL_EMAIL).bimap( + storage.updateBundle(Bundle(bundleId, 0)) + storage.getBundles(IDENTITY).bimap( { fail(it.message) }, { bundles -> assertEquals(0L, - bundles.firstOrNull { it.id == EPHERMERAL_EMAIL }?.balance) + bundles.firstOrNull { it.id == bundleId }?.balance) }) } @@ -81,16 +86,17 @@ class Neo4jStorageTest { val purchase = PurchaseRecord( product = DATA_TOPUP_3GB, timestamp = now, - id = UUID.randomUUID().toString(), - msisdn = "") + id = UUID.randomUUID().toString()) storage.addPurchaseRecord(EPHERMERAL_EMAIL, purchase) } companion object { private const val EPHERMERAL_EMAIL = "attherate@dotcom.com" + private const val NAME = "Some Name" private const val MSISDN = "4747116996" - private const val COUNTRY = "NO" + + private val IDENTITY = Identity(EPHERMERAL_EMAIL, "EMAIL", "email") private const val MILLIS_TO_WAIT_WHEN_STARTING_UP = 3000 @@ -114,16 +120,13 @@ class Neo4jStorageTest { initFirebaseConfigRegistry() - val config = Config() - config.host = "0.0.0.0" - config.protocol = "bolt" - ConfigRegistry.config = config + ConfigRegistry.config = Config( + host = "0.0.0.0", + protocol = "bolt") Neo4jClient.start() initDatabase() - - OcsPrimeServiceSingleton.init(mock(EventProducer::class.java)) } @JvmStatic diff --git a/prime/src/integration-tests/resources/config.yaml b/prime/src/integration-tests/resources/config.yaml index fb42b7026..3b1bac5c2 100644 --- a/prime/src/integration-tests/resources/config.yaml +++ b/prime/src/integration-tests/resources/config.yaml @@ -1,40 +1,46 @@ modules: -- type: jersey -- type: firebase - config: - configFile: config/pantel-prod.json - rootPath: test -- type: neo4j - config: - host: 0.0.0.0 - protocol: bolt -- type: analytics - config: - projectId: pantel-2decb - dataTrafficTopicId: data-traffic - purchaseInfoTopicId: purchase-info - activeUsersTopicId: active-users -- type: ocs - config: - lowBalanceThreshold: 0 -- type: pseudonymizer -- type: api - config: - authenticationCachePolicy: maximumSize=10000, expireAfterAccess=10m - jerseyClient: - timeout: 3s -- type: stripe-payment-processor -- type: firebase-app-notifier - config: - configFile: config/pantel-prod.json -- type: admin - + - type: jersey + config: + authenticationCachePolicy: maximumSize=10000, expireAfterAccess=10m + jerseyClient: + timeout: 3s + - type: firebase + config: + configFile: config/prime-service-account.json + rootPath: test + - type: neo4j + config: + host: 0.0.0.0 + protocol: bolt + - type: analytics + config: + projectId: ${GCP_PROJECT_ID} + dataTrafficTopicId: data-traffic + purchaseInfoTopicId: purchase-info + activeUsersTopicId: active-users + - type: ocs + config: + lowBalanceThreshold: 0 + - type: api + - type: stripe-payment-processor + config: + projectId: ${GCP_PROJECT_ID} + stripeEventTopicId: stripe-event + stripeEventStoreSubscriptionId: stripe-event-store-sub + stripeEventReportSubscriptionId: stripe-event-report-sub + - type: firebase-app-notifier + config: + configFile: config/prime-service-account.json + - type: admin + - type: scaninfo-store + config: + storeType: emulator server: applicationConnectors: - - type: h2c - port: 8080 - maxConcurrentStreams: 1024 - initialStreamRecvWindow: 65535 + - type: h2c + port: 8080 + maxConcurrentStreams: 1024 + initialStreamRecvWindow: 65535 logging: level: INFO diff --git a/prime/src/integration-tests/resources/docker-compose.yaml b/prime/src/integration-tests/resources/docker-compose.yaml index 76e311981..ed672849e 100644 --- a/prime/src/integration-tests/resources/docker-compose.yaml +++ b/prime/src/integration-tests/resources/docker-compose.yaml @@ -3,7 +3,7 @@ version: "3.3" services: neo4j: container_name: "neo4j" - image: neo4j:3.4.8 + image: neo4j:3.4.9 environment: - NEO4J_AUTH=none ports: diff --git a/prime/src/main/kotlin/org/ostelco/prime/PrimeApplication.kt b/prime/src/main/kotlin/org/ostelco/prime/PrimeApplication.kt index 4cb268b29..948f10709 100644 --- a/prime/src/main/kotlin/org/ostelco/prime/PrimeApplication.kt +++ b/prime/src/main/kotlin/org/ostelco/prime/PrimeApplication.kt @@ -8,9 +8,7 @@ import io.dropwizard.setup.Bootstrap import io.dropwizard.setup.Environment import org.dhatim.dropwizard.prometheus.PrometheusBundle -fun main(args: Array) { - PrimeApplication().run(*args) -} +fun main(args: Array) = PrimeApplication().run(*args) class PrimeApplication : Application() { @@ -24,8 +22,5 @@ class PrimeApplication : Application() { override fun run( primeConfiguration: PrimeConfiguration, - environment: Environment) { - - primeConfiguration.modules.forEach { it.init(environment) } - } + environment: Environment) = primeConfiguration.modules.forEach { it.init(environment) } } diff --git a/pseudonym-server/README.md b/pseudonym-server/README.md deleted file mode 100644 index 94aab5efb..000000000 --- a/pseudonym-server/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Module Pseudonym Server - -SQL for joining dataconsumption and pseudonyms table - - SELECT - hc.bytes, ps.msisdnid, hc.timestamp - FROM - [pantel-2decb:data_consumption.hourly_consumption] as hc - JOIN - [pantel-2decb:exported_pseudonyms.3ebcdc4a7ecc4cd385e82087e49b7b7b] as ps - ON ps.msisdn = hc.msisdn - -Login to eu.gcr.io for pushing images - - docker login -u oauth2accesstoken -p "$(gcloud auth print-access-token)" https://eu.gcr.io - diff --git a/pseudonym-server/build.gradle b/pseudonym-server/build.gradle deleted file mode 100644 index 8d2652abe..000000000 --- a/pseudonym-server/build.gradle +++ /dev/null @@ -1,30 +0,0 @@ -plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" - id "java-library" -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - - implementation project(':ocs-grpc-api') - implementation project(':prime-modules') - implementation project(':model') - implementation project(':analytics-grpc-api') - - implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" - implementation "com.google.guava:guava:$guavaVersion" - // Match with grpc-netty-shaded via PubSub - // removing io.grpc:grpc-netty-shaded causes ALPN error - implementation "io.grpc:grpc-netty-shaded:$grpcVersion" - implementation "com.google.cloud:google-cloud-bigquery:$googleCloudVersion" - implementation "com.google.cloud:google-cloud-datastore:$googleCloudVersion" - implementation "com.google.cloud:google-cloud-pubsub:$googleCloudVersion" - - - testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" - testImplementation "org.mockito:mockito-all:1.10.19" - testRuntimeOnly 'org.hamcrest:hamcrest-all:1.3' -} - -apply from: '../jacoco.gradle' diff --git a/pseudonym-server/script/check_repo.sh b/pseudonym-server/script/check_repo.sh deleted file mode 100755 index d46d03b44..000000000 --- a/pseudonym-server/script/check_repo.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [ -z "$1" ] || [ -z "$2" ]; then - (>&2 echo "Usage: check_repo.sh ") - exit 1 -fi - -module=$1 -tag=$2 - -echo "Checking if '$tag' exists in master branch" - -if [ ! -f "$module/script/deploy.sh" ]; then - (>&2 echo "Run this script from project root dir (ostelco-core)") - exit 1 -fi - -CHECK_REPO="$module/script/check_repo.sh" - -if [ ! -f ${CHECK_REPO} ]; then - (>&2 echo "Missing file - $CHECK_REPO") - exit 1 -fi - -command -v git >/dev/null 2>&1 || { echo >&2 "Git not available, Aborting."; exit 1; } - -# Find if the commit for the git tag exist in master branch. - -# We need a full checkout to search the tag. -git clone https://github.com/ostelco/ostelco-core.git -cd ostelco-core -git checkout master - -tag_commit=$(git rev-list -n 1 $tag) -echo "Searching for '$tag_commit'" - -if [ -z "$tag_commit" ]; then - (>&2 echo "Cannot find commit for '$tag'") - exit 1 -fi - -# Look if the commit is in master (reachable by first-parent, so not deep) -if git rev-list --first-parent master | grep $tag_commit >/dev/null; then - (>&2 echo "$tag points to a commit in master") -else - (>&2 echo "$tag does not point to a commit in master") - exit 1 -fi - -# remove the new checkout. -cd .. -rm -rf ostelco-core - -exit 0 diff --git a/pseudonym-server/script/deploy.sh b/pseudonym-server/script/deploy.sh deleted file mode 100755 index 85a94c9a0..000000000 --- a/pseudonym-server/script/deploy.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash - -set -e -if [ -z "$1" ] || [ -z "$2" ]; then - (>&2 echo "Usage: deploy.sh ") - exit 1 -fi -module=$1 -tag=$2 - -if [ ! -f "$module/script/deploy.sh" ]; then - (>&2 echo "Run this script from project root dir (ostelco-core)") - exit 1 -fi - -CHECK_REPO="$module/script/check_repo.sh" - -if [ ! -f ${CHECK_REPO} ]; then - (>&2 echo "Missing file - $CHECK_REPO") - exit 1 -fi - -PROJECT_ID="$(gcloud config get-value project -q)" -BRANCH_NAME=$(git branch | grep \* | cut -d ' ' -f2) - -echo PROJECT_ID=${PROJECT_ID} -echo BRANCH_NAME=${BRANCH_NAME} - -echo "Deploying $module to GKE" - -gcloud container builds submit \ - --config pseudonym-server/cloudbuild.yaml \ - --substitutions TAG_NAME=${tag},BRANCH_NAME=${BRANCH_NAME} . diff --git a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/Model.kt b/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/Model.kt deleted file mode 100644 index e02687f3c..000000000 --- a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/Model.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.ostelco.pseudonym - -const val MsisdnPseudonymEntityKind = "Pseudonym" -const val msisdnPropertyName = "msisdn" -const val pseudonymPropertyName = "pseudonym" -const val startPropertyName = "start" -const val endPropertyName = "end" -const val SubscriberIdPseudonymEntityKind = "SubscriberPseudonym" -const val subscriberIdPropertyName = "subscriberId" - -const val ExportTaskKind = "ExportTask" -const val exportIdPropertyName = "exportId" -const val statusPropertyName = "status" -const val errorPropertyName = "error" - diff --git a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/PseudonymModule.kt b/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/PseudonymModule.kt deleted file mode 100644 index e4849d0fa..000000000 --- a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/PseudonymModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.ostelco.pseudonym - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.annotation.JsonTypeName -import io.dropwizard.Configuration -import io.dropwizard.setup.Environment -import org.ostelco.prime.module.PrimeModule -import org.ostelco.pseudonym.resources.PseudonymResource -import org.ostelco.pseudonym.service.PseudonymizerServiceSingleton - -@JsonTypeName("pseudonymizer") -class PseudonymModule : PrimeModule { - - @JsonProperty - fun setConfig(config: PseudonymServerConfig) { - ConfigRegistry.config = config - } - - override fun init(env: Environment) { - PseudonymizerServiceSingleton.init(env = env) - env.jersey().register(PseudonymResource()) - } -} - -object ConfigRegistry { - var config = PseudonymServerConfig() -} - -/** - * The configuration for Pseudonymiser. - */ -class PseudonymServerConfig : Configuration() { - var datastoreType = "default" - var namespace = "" -} \ No newline at end of file diff --git a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/resources/PseudonymResource.kt b/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/resources/PseudonymResource.kt deleted file mode 100644 index 85a6f37c9..000000000 --- a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/resources/PseudonymResource.kt +++ /dev/null @@ -1,124 +0,0 @@ -package org.ostelco.pseudonym.resources - -import org.hibernate.validator.constraints.NotBlank -import org.ostelco.pseudonym.service.PseudonymizerServiceSingleton -import org.slf4j.LoggerFactory -import java.time.Instant -import javax.ws.rs.DELETE -import javax.ws.rs.GET -import javax.ws.rs.Path -import javax.ws.rs.PathParam -import javax.ws.rs.core.MediaType -import javax.ws.rs.core.Response -import javax.ws.rs.core.Response.Status - - -/** - * Class representing the Export task entity in Datastore. - */ -data class ExportTask(val exportId: String, val status: String, val error: String) - -/** - * Resource used to handle the pseudonym related REST calls. The map of pseudonym objects - * are store in datastore. The key for the object is made from "-. - */ -@Path("/pseudonym") -class PseudonymResource { - - private val logger = LoggerFactory.getLogger(PseudonymResource::class.java) - - /** - * Get the pseudonym which is valid at the timestamp for the given - * msisdn. In case pseudonym doesn't exist, a new one will be created - * for the period. Timestamps are in UTC - */ - @GET - @Path("/get/{msisdn}/{timestamp}") - fun getPseudonym(@NotBlank @PathParam("msisdn") msisdn: String, - @NotBlank @PathParam("timestamp") timestamp: String): Response { - logger.info("GET pseudonym for Msisdn = $msisdn at timestamp = $timestamp") - val entity = PseudonymizerServiceSingleton.getMsisdnPseudonym(msisdn, timestamp.toLong()) - return Response.ok(entity, MediaType.APPLICATION_JSON).build() - } - - /** - * Get the pseudonym which is valid at the time of the call for the given - * msisdn. In case pseudonym doesn't exist, a new one will be created - * for the period - */ - @GET - @Path("/current/{msisdn}") - fun getPseudonym(@NotBlank @PathParam("msisdn") msisdn: String): Response { - val timestamp = Instant.now().toEpochMilli() - logger.info("GET pseudonym for Msisdn = $msisdn at current time, timestamp = $timestamp") - val entity = PseudonymizerServiceSingleton.getMsisdnPseudonym(msisdn, timestamp) - return Response.ok(entity, MediaType.APPLICATION_JSON).build() - } - - /** - * Get the pseudonyms valid for current & next time periods for the given - * msisdn. In case pseudonym doesn't exist, a new one will be created - * for the periods - */ - @GET - @Path("/active/{msisdn}") - fun getActivePseudonyms(@NotBlank @PathParam("msisdn") msisdn: String): Response { - return Response.ok( - PseudonymizerServiceSingleton.getActivePseudonymsForMsisdn(msisdn = msisdn), - MediaType.APPLICATION_JSON).build() - } - - /** - * Find the msisdn and other details about the given pseudonym. - * In case pseudonym doesn't exist, it returns 404 - */ - @GET - @Path("/find/{pseudonym}") - fun findPseudonym(@NotBlank @PathParam("pseudonym") pseudonym: String): Response { - logger.info("Find details for pseudonym = $pseudonym") - return PseudonymizerServiceSingleton.findMsisdnPseudonym(pseudonym = pseudonym) - ?.let { Response.ok(it, MediaType.APPLICATION_JSON).build() } - ?: Response.status(Status.NOT_FOUND).build() - } - - /** - * Delete all pseudonym entities for the given msisdn - * Returns a json object with no of records deleted. - * { count : } - */ - @DELETE - @Path("/delete/{msisdn}") - fun deleteAllPseudonyms(@NotBlank @PathParam("msisdn") msisdn: String): Response { - logger.info("delete all pseudonyms for Msisdn = $msisdn") - val count = PseudonymizerServiceSingleton.deleteAllMsisdnPseudonyms(msisdn = msisdn) - // Return a Json object with number of records deleted. - val countMap = mapOf("count" to count) - logger.info("deleted $count records for Msisdn = $msisdn") - return Response.ok(countMap, MediaType.APPLICATION_JSON).build() - } - - /** - * Exports all pseudonyms to a bigquery table. the name is generated from the exportId - * parameter. The exportId is expected to be a UUIDV4. The '-' are removed from the name. - * Return Ok when the process has started, used /exportstatus to get the result. - */ - @GET - @Path("/export/{exportId}") - fun exportPseudonyms(@NotBlank @PathParam("exportId") exportId: String): Response { - logger.info("GET export all pseudonyms to the table $exportId") - PseudonymizerServiceSingleton.exportMsisdnPseudonyms(exportId = exportId) - return Response.ok("Started Exporting", MediaType.TEXT_PLAIN).build() - } - - /** - * Returns the result of the export. Return 404 if exportId id not found in the system - */ - @GET - @Path("/exportstatus/{exportId}") - fun getExportStatus(@NotBlank @PathParam("exportId") exportId: String): Response { - logger.info("GET status of export $exportId") - return PseudonymizerServiceSingleton.getExportTask(exportId) - ?.let { Response.ok(it, MediaType.APPLICATION_JSON).build() } - ?: Response.status(Status.NOT_FOUND).build() - } -} diff --git a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/service/PseudonymExport.kt b/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/service/PseudonymExport.kt deleted file mode 100644 index 8ed1adea1..000000000 --- a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/service/PseudonymExport.kt +++ /dev/null @@ -1,310 +0,0 @@ -package org.ostelco.pseudonym.service - -import com.google.cloud.bigquery.BigQuery -import com.google.cloud.bigquery.Field -import com.google.cloud.bigquery.InsertAllRequest.RowToInsert -import com.google.cloud.bigquery.LegacySQLTypeName -import com.google.cloud.bigquery.Schema -import com.google.cloud.bigquery.StandardTableDefinition -import com.google.cloud.bigquery.Table -import com.google.cloud.bigquery.TableId -import com.google.cloud.bigquery.TableInfo -import com.google.cloud.datastore.Cursor -import com.google.cloud.datastore.Datastore -import com.google.cloud.datastore.Entity -import com.google.cloud.datastore.Query -import com.google.cloud.datastore.StructuredQuery -import com.google.common.cache.Cache -import com.google.common.cache.CacheBuilder -import org.apache.commons.codec.binary.Hex -import org.ostelco.prime.model.Subscriber -import org.ostelco.prime.model.Subscription -import org.ostelco.prime.module.getResource -import org.ostelco.prime.storage.AdminDataSource -import org.ostelco.pseudonym.* -import org.slf4j.LoggerFactory -import java.net.URLEncoder -import java.security.MessageDigest -import java.util.UUID - -private const val datasetName = "exported_pseudonyms" -private const val consumptionDatasetName = "exported_data_consumption" - -private const val idFieldName = "pseudoid" -private const val msisdnIdPropertyName = "msisdnId" - -/** - * Exports pseudonym objects to a bigquery Table - */ -class PseudonymExport(private val exportId: String, private val bigquery: BigQuery, private val datastore: Datastore) { - private val logger = LoggerFactory.getLogger(PseudonymExport::class.java) - - /** - * Status of the export operation in progress. - */ - enum class Status { - INITIAL, RUNNING, FINISHED, ERROR - } - - private var status = Status.INITIAL - private var error: String = "" - private val randomKey = "$exportId-${UUID.randomUUID()}" - private val msisdnExporter: DS2BQExporter = DS2BQExporter( - tableName = tableName("msisdn"), - sourceEntity = MsisdnPseudonymEntityKind, - sourceField = msisdnPropertyName, - datasetName = datasetName, - randomKey = randomKey, - datastore = datastore, - bigquery = bigquery) - private val subscriberIdExporter: DS2BQExporter = DS2BQExporter( - tableName = tableName("subscriber"), - sourceEntity = SubscriberIdPseudonymEntityKind, - sourceField = subscriberIdPropertyName, - datasetName = datasetName, - randomKey = randomKey, - datastore = datastore, - bigquery = bigquery) - private val msisdnMappingExporter: SubscriberMsisdnMappingExporter = SubscriberMsisdnMappingExporter( - tableName = tableName("sub2msisdn"), - msisdnExporter = msisdnExporter, - subscriberIdExporter = subscriberIdExporter, - datasetName = consumptionDatasetName, - bigquery = bigquery) - - init { - upsertTaskStatus() - } - - private fun tableName(suffix: String) = "${exportId.replace("-", "")}_$suffix" - - private fun start() { - logger.info("Starting to export Pseudonyms for $exportId") - status = Status.RUNNING - upsertTaskStatus() - msisdnExporter.doExport() - subscriberIdExporter.doExport() - msisdnMappingExporter.doExport() - if (status == Status.RUNNING) { - status = Status.FINISHED - upsertTaskStatus() - } - logger.info("Exported msisdn and subscriber pseudonyms for $exportId") - } - - /** - * Returns a runnable that can be passed to executor. It starts the - * export operation. - */ - fun getRunnable(): Runnable { - return Runnable { - start() - } - } - - private fun upsertTaskStatus() { - val exportKey = datastore.newKeyFactory().setKind(ExportTaskKind).newKey(exportId) - val transaction = datastore.newTransaction() - try { - // Verify before writing a new value. - val currentEntity = transaction.get(exportKey) - val builder: Entity.Builder = - if (currentEntity == null) { - Entity.newBuilder(exportKey) - } else { - Entity.newBuilder(currentEntity) - } - // Prepare the new datastore entity - val exportTask = builder - .set(exportIdPropertyName, exportId) - .set(statusPropertyName, status.toString()) - .set(errorPropertyName, error) - .build() - transaction.put(exportTask) - transaction.commit() - } finally { - if (transaction.isActive) { - transaction.rollback() - } - } - } - - -} - -/** - * Class for exporting Datastore tables to BigQuery. - */ -class DS2BQExporter( - tableName: String, - private val sourceEntity: String, - private val sourceField: String, - datasetName: String, - private val randomKey: String, - private val datastore: Datastore, - bigquery: BigQuery): BQExporter(tableName, randomKey, datasetName, bigquery) { - - override val logger = LoggerFactory.getLogger(DS2BQExporter::class.java) - private val idCache: Cache = CacheBuilder.newBuilder() - .maximumSize(5000) - .build() - private val digest = MessageDigest.getInstance("SHA-256") - - override fun getSchema(): Schema { - val id = Field.of(idFieldName, LegacySQLTypeName.STRING) - val pseudonym = Field.of(pseudonymPropertyName, LegacySQLTypeName.STRING) - val source = Field.of(sourceField, LegacySQLTypeName.STRING) - return Schema.of(id, pseudonym, source) - } - - fun getIdForKey(key: String): String { - // Retrieves the element from cache. - // Incase of cache miss, generate a new SHA - return idCache.get(key) { - val keyString: String = "$randomKey-$key" - val hash = digest.digest(keyString.toByteArray(Charsets.UTF_8)) - String(Hex.encodeHex(hash)) - } - } - - fun exportPage(pageSize: Int, cursor: Cursor?, table: Table): Cursor? { - // Dump pseudonyms to BQ, one page at a time. Since all records in a - // page are inserted at once, use a small page size - val queryBuilder = Query.newEntityQueryBuilder() - .setKind(sourceEntity) - .setOrderBy(StructuredQuery.OrderBy.asc(sourceField)) - .setLimit(pageSize) - if (cursor != null) { - queryBuilder.setStartCursor(cursor) - } - val rows = ArrayList() - val pseudonyms = datastore.run(queryBuilder.build()) - while (pseudonyms.hasNext()) { - val entity = pseudonyms.next() - totalRows++ - val row = hashMapOf( - sourceField to entity.getString(sourceField), - pseudonymPropertyName to entity.getString(pseudonymPropertyName), - idFieldName to getIdForKey(entity.getString(sourceField))) - val rowId = "rowId$totalRows" - rows.add(RowToInsert.of(rowId, row)) - } - insertToBq(table, rows) - return if (rows.size < pageSize) { - null - } else { - pseudonyms.cursorAfter - } - } - - /** - * Export the Datastore table to BQ. - * This is done in pages of 100 records. - */ - override fun doExport() { - logger.info("Starting export to ${tableName}") - val table = createTable() - var cursor: Cursor? = null - do { - cursor = exportPage(100, cursor, table) - } while (cursor != null) - logger.info("Exported ${totalRows} rows to ${tableName}") - } -} - - -/** - * Class for exporting Subscriber -> Msisidn mapping. - */ -class SubscriberMsisdnMappingExporter( - tableName: String, - private val msisdnExporter: DS2BQExporter, - private val subscriberIdExporter: DS2BQExporter, - datasetName: String, - bigquery: BigQuery) : - BQExporter(tableName, "", datasetName, bigquery) { - - private val storage by lazy { getResource() } - override val logger = LoggerFactory.getLogger(SubscriberMsisdnMappingExporter::class.java) - - override fun getSchema(): Schema { - val subscriberId = Field.of(subscriberIdPropertyName, LegacySQLTypeName.STRING) - val msisdnId = Field.of(msisdnIdPropertyName, LegacySQLTypeName.STRING) - return Schema.of(subscriberId, msisdnId) - } - - private fun exportAllPages(table: Table, pageSize: Int) { - // Dump pseudonyms to BQ, one page at a time. Since all records in a - // page are inserted at once, use a small page size - val map: Map = storage.getSubscriberToMsisdnMap() - var rows = ArrayList() - for ((subscriber, subscription) in map) { - val encodedSubscriberId = URLEncoder.encode(subscriber.email, "UTF-8") - totalRows++ - val row = hashMapOf( - msisdnIdPropertyName to msisdnExporter.getIdForKey(subscription.msisdn), - subscriberIdPropertyName to subscriberIdExporter.getIdForKey(encodedSubscriberId)) - val rowId = "rowId$totalRows" - rows.add(RowToInsert.of(rowId, row)) - if (rows.size == pageSize) { - // Insert current page to BQ - insertToBq(table, rows) - // Reset rows array. - rows = ArrayList() - } - } - // Insert remaining rows to BQ - insertToBq(table, rows) - } - - /** - * Export all subscription mapping to BQ. - * This is done in pages of 100 records. - */ - override fun doExport() { - logger.info("Starting export to ${tableName}") - val table = createTable() - exportAllPages(table, 100) - logger.info("Exported ${totalRows} rows to ${tableName}") - } -} - -/** - * Class for exporting Subscriber -> Msisidn mapping. - */ -abstract class BQExporter( - val tableName: String, - private val randomKey: String, - private val datasetName: String, - private val bigquery: BigQuery) { - - open val logger = LoggerFactory.getLogger(BQExporter::class.java) - var error: String = "" - var totalRows = 0 - - fun createTable(): Table { - // Delete existing table - val deleted = bigquery.delete(datasetName, tableName) - if (deleted) { - logger.info("Existing table '$tableName' deleted.") - } - val tableId = TableId.of(datasetName, tableName) - val schema = getSchema() - val tableDefinition = StandardTableDefinition.of(schema) - val tableInfo = TableInfo.newBuilder(tableId, tableDefinition).build() - return bigquery.create(tableInfo) - } - - fun insertToBq(table: Table, rows: ArrayList) { - if (rows.size != 0) { - val response = table.insert(rows, true, true) - if (response.hasErrors()) { - logger.error("Failed to insert Records to '$tableName'", response.insertErrors) - error = "$error${response.insertErrors}\n" - } - } - } - - abstract fun getSchema(): Schema - abstract fun doExport() -} diff --git a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/service/PseudonymizerServiceSingleton.kt b/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/service/PseudonymizerServiceSingleton.kt deleted file mode 100644 index 54c148987..000000000 --- a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/service/PseudonymizerServiceSingleton.kt +++ /dev/null @@ -1,289 +0,0 @@ -package org.ostelco.pseudonym.service - -import com.codahale.metrics.health.HealthCheck -import com.google.cloud.NoCredentials -import com.google.cloud.bigquery.BigQuery -import com.google.cloud.bigquery.BigQueryOptions -import com.google.cloud.datastore.* -import com.google.cloud.datastore.StructuredQuery.PropertyFilter -import com.google.cloud.datastore.testing.LocalDatastoreHelper -import com.google.cloud.http.HttpTransportOptions -import com.google.common.cache.Cache -import com.google.common.cache.CacheBuilder -import io.dropwizard.setup.Environment -import org.ostelco.prime.getLogger -import org.ostelco.prime.model.ActivePseudonyms -import org.ostelco.prime.model.PseudonymEntity -import org.ostelco.prime.pseudonymizer.PseudonymizerService -import org.ostelco.pseudonym.* -import org.ostelco.pseudonym.resources.ExportTask -import org.ostelco.pseudonym.utils.WeeklyBounds -import java.time.Instant -import java.util.* -import java.util.concurrent.Executors - -class PseudonymServiceImpl : PseudonymizerService by PseudonymizerServiceSingleton - -/** - * Class representing the boundary timestamps. - */ -data class Bounds(val start: Long, val end: Long) - -/** - * Interface which provides the method to retrieve the boundary timestamps. - */ -interface DateBounds { - /** - * Returns the boundaries for the period of the given timestamp. - * (start <= timestamp <= end). Timestamps are in UTC - * Also returns the key prefix - */ - fun getBoundsNKeyPrefix(msisdn: String, timestamp: Long): Pair - - /** - * Returns the timestamp for start of the next period for given timestamp. - * (value > timestamp). Timestamps are in UTC - */ - fun getNextPeriodStart(timestamp: Long): Long -} - -object PseudonymizerServiceSingleton : PseudonymizerService { - - private val logger by getLogger() - - private lateinit var datastore: Datastore - private var bigQuery: BigQuery? = null - private val dateBounds: DateBounds = WeeklyBounds() - - private val msisdnPseudonymiser: Pseudonymizer = Pseudonymizer(MsisdnPseudonymEntityKind, msisdnPropertyName) - private val subscriberIdPseudonymiser: Pseudonymizer = Pseudonymizer(SubscriberIdPseudonymEntityKind, subscriberIdPropertyName) - private val executor = Executors.newFixedThreadPool(3) - - private val msisdnPseudonymCache: Cache = CacheBuilder.newBuilder() - .maximumSize(5000) - .build() - private val subscriberIdPseudonymCache: Cache = CacheBuilder.newBuilder() - .maximumSize(5000) - .build() - - fun init(env: Environment?, bq: BigQuery? = null) { - - initDatastore(env) - - bigQuery = bq ?: if (System.getenv("LOCAL_TESTING") != "true") { - BigQueryOptions.getDefaultInstance().service - } else { - logger.info("Local testing, BigQuery is not available...") - null - } - msisdnPseudonymiser.init(datastore, bigQuery, dateBounds) - subscriberIdPseudonymiser.init(datastore, bigQuery, dateBounds) - } - - override fun getActivePseudonymsForMsisdn(msisdn: String): ActivePseudonyms { - val currentTimestamp = Instant.now().toEpochMilli() - val nextTimestamp = dateBounds.getNextPeriodStart(currentTimestamp) - logger.info("GET pseudonym for Msisdn = $msisdn at timestamps = $currentTimestamp & $nextTimestamp") - val current = getMsisdnPseudonym(msisdn, currentTimestamp) - val next = getMsisdnPseudonym(msisdn, nextTimestamp) - return ActivePseudonyms(current, next) - } - - override fun getMsisdnPseudonym(msisdn: String, timestamp: Long): PseudonymEntity { - val (bounds, keyPrefix) = dateBounds.getBoundsNKeyPrefix(msisdn, timestamp) - // Retrieves the element from cache. - return msisdnPseudonymCache.get(keyPrefix) { - msisdnPseudonymiser.getPseudonymEntity(keyPrefix) - ?: msisdnPseudonymiser.createPseudonym(msisdn, bounds, keyPrefix) - } - } - - override fun getSubscriberIdPseudonym(subscriberId: String, timestamp: Long): PseudonymEntity { - val (bounds, keyPrefix) = dateBounds.getBoundsNKeyPrefix(subscriberId, timestamp) - // Retrieves the element from cache. - return subscriberIdPseudonymCache.get(keyPrefix) { - subscriberIdPseudonymiser.getPseudonymEntity(keyPrefix) - ?: subscriberIdPseudonymiser.createPseudonym(subscriberId, bounds, keyPrefix) - } - } - - fun findMsisdnPseudonym(pseudonym: String): PseudonymEntity? { - return msisdnPseudonymiser.findPseudonym(pseudonym) - } - - fun deleteAllMsisdnPseudonyms(msisdn: String): Int { - return msisdnPseudonymiser.deleteAllPseudonyms(msisdn) - } - - fun exportMsisdnPseudonyms(exportId: String) { - bigQuery?.apply { - logger.info("GET export all pseudonyms to the table $exportId") - val exporter = PseudonymExport(exportId = exportId, bigquery = this, datastore = datastore) - executor.execute(exporter.getRunnable()) - } - } - - // Integration testing helper for Datastore. - private fun initDatastore(env: Environment?) { - datastore = when (ConfigRegistry.config.datastoreType) { - "inmemory-emulator" -> { - logger.info("Starting with in-memory datastore emulator") - val helper: LocalDatastoreHelper = LocalDatastoreHelper.create(1.0) - helper.start() - helper.options - } - "emulator" -> { - // When prime running in GCP by hosted CI/CD, Datastore client library assumes it is running in - // production and ignore our instruction to connect to the datastore emulator. So, we are explicitly - // connecting to emulator - logger.info("Connecting to datastore emulator") - DatastoreOptions - .newBuilder() - .setHost("localhost:9090") - .setCredentials(NoCredentials.getInstance()) - .setTransportOptions(HttpTransportOptions.newBuilder().build()) - .build() - } - else -> { - logger.info("Created default instance of datastore client") - DatastoreOptions - .newBuilder() - .setNamespace(ConfigRegistry.config.namespace) - .build() - } - }.service - - // health-check for datastore - env?.healthChecks()?.register("datastore", object : HealthCheck() { - override fun check(): Result { - try { - val testKey = datastore.newKeyFactory().setKind("TestKind").newKey("testKey") - val testPropertyKey = "testPropertyKey" - val testPropertyValue = "testPropertyValue" - val testEntity = Entity.newBuilder(testKey).set(testPropertyKey, testPropertyValue).build() - datastore.put(testEntity) - val value = datastore.get(testKey).getString(testPropertyKey) - datastore.delete(testKey) - if (testPropertyValue != value) { - logger.warn("Unable to fetch test property value from datastore") - return Result.builder().unhealthy().build() - } - return Result.builder().healthy().build() - } catch (e: Exception) { - return Result.builder().unhealthy(e).build() - } - } - }) - } - - fun getExportTask(exportId: String): ExportTask? { - val exportKey = datastore.newKeyFactory().setKind(ExportTaskKind).newKey(exportId) - val value = datastore.get(exportKey) - if (value != null) { - // Create the object from datastore entity - return ExportTask( - value.getString(exportIdPropertyName), - value.getString(statusPropertyName), - value.getString(errorPropertyName)) - } - return null - } -} - - -class Pseudonymizer(val entityKind: String, val sourcePropertyName: String) { - private val logger by getLogger() - private lateinit var datastore: Datastore - private var bigQuery: BigQuery? = null - private lateinit var dateBounds: DateBounds - - fun init(ds: Datastore, bq: BigQuery? = null, bounds: DateBounds) { - datastore = ds - bigQuery = bq - dateBounds = bounds - } - - fun findPseudonym(pseudonym: String): PseudonymEntity? { - val query = Query.newEntityQueryBuilder() - .setKind(entityKind) - .setFilter(PropertyFilter.eq(pseudonymPropertyName, pseudonym)) - .setLimit(1) - .build() - val results = datastore.run(query) - if (results.hasNext()) { - val entity = results.next() - return convertToPseudonymEntity(entity) - } - logger.info("Couldn't find, pseudonym = $pseudonym") - return null - } - - fun deleteAllPseudonyms(sourceId: String): Int { - val query = Query.newEntityQueryBuilder() - .setKind(entityKind) - .setFilter(PropertyFilter.eq(sourcePropertyName, sourceId)) - .setLimit(1) - .build() - val results = datastore.run(query) - var count = 0 - while (results.hasNext()) { - val entity = results.next() - datastore.delete(entity.key) - count++ - } - return count - } - - private fun getPseudonymKey(keyPrefix: String): Key { - return datastore.newKeyFactory().setKind(entityKind).newKey(keyPrefix) - } - - fun getPseudonymEntity(keyPrefix: String): PseudonymEntity? { - val pseudonymKey = getPseudonymKey(keyPrefix) - val value = datastore.get(pseudonymKey) - if (value != null) { - // Create the object from datastore entity - return convertToPseudonymEntity(value) - } - return null - } - - fun createPseudonym(sourceId: String, bounds: Bounds, keyPrefix: String): PseudonymEntity { - val uuid = UUID.randomUUID().toString() - var entity = PseudonymEntity(sourceId, uuid, bounds.start, bounds.end) - val pseudonymKey = getPseudonymKey(keyPrefix) - - val transaction = datastore.newTransaction() - try { - // Verify before writing a new value. - val currentEntity = transaction.get(pseudonymKey) - if (currentEntity == null) { - // Prepare the new datastore entity - val pseudonym = Entity.newBuilder(pseudonymKey) - .set(sourcePropertyName, entity.sourceId) - .set(pseudonymPropertyName, entity.pseudonym) - .set(startPropertyName, entity.start) - .set(endPropertyName, entity.end) - .build() - transaction.put(pseudonym) - transaction.commit() - } else { - // Use the existing one - entity = convertToPseudonymEntity(currentEntity) - } - } finally { - if (transaction.isActive) { - transaction.rollback() - } - } - return entity - } - - private fun convertToPseudonymEntity(entity: Entity): PseudonymEntity { - return PseudonymEntity( - entity.getString(sourcePropertyName), - entity.getString(pseudonymPropertyName), - entity.getLong(startPropertyName), - entity.getLong(endPropertyName)) - } -} diff --git a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/utils/DateUtils.kt b/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/utils/DateUtils.kt deleted file mode 100644 index 7c3fbdf7f..000000000 --- a/pseudonym-server/src/main/kotlin/org/ostelco/pseudonym/utils/DateUtils.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.ostelco.pseudonym.utils - -import org.ostelco.pseudonym.service.Bounds -import org.ostelco.pseudonym.service.DateBounds -import java.util.* - -/** - * Implements DateBounds interface which provides boundary timestamps - * for a week. - */ -class WeeklyBounds : DateBounds { - private val timeZone = TimeZone.getTimeZone("UTC") - private val locale = java.util.Locale.UK - /** - * Returns the boundaries for the week of the given timestamp. - */ - fun getBounds(timestamp: Long): Pair { - val cal = Calendar.getInstance(timeZone, locale) - cal.timeInMillis = timestamp - cal.set(Calendar.HOUR_OF_DAY, 0) - cal.clear(Calendar.MINUTE) - cal.clear(Calendar.SECOND) - cal.clear(Calendar.MILLISECOND) - - cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) - val start = cal.timeInMillis - cal.add(Calendar.WEEK_OF_YEAR, 1) - cal.add(Calendar.MILLISECOND, -1) - val end = cal.timeInMillis - - return Pair(start, end) - } - - override fun getNextPeriodStart(timestamp: Long): Long { - val cal = Calendar.getInstance(timeZone, locale) - cal.timeInMillis = timestamp - cal.set(Calendar.HOUR_OF_DAY, 0) - cal.clear(Calendar.MINUTE) - cal.clear(Calendar.SECOND) - cal.clear(Calendar.MILLISECOND) - - cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) - cal.add(Calendar.WEEK_OF_YEAR, 1) - return cal.timeInMillis - } - - override fun getBoundsNKeyPrefix(msisdn: String, timestamp: Long): Pair { - val bounds = getBounds(timestamp) - val keyPrefix = "$msisdn-${bounds.first}" - return Pair(Bounds(bounds.first, bounds.second), keyPrefix) - } -} diff --git a/pseudonym-server/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/pseudonym-server/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule deleted file mode 100644 index 9e15e0fdf..000000000 --- a/pseudonym-server/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule +++ /dev/null @@ -1 +0,0 @@ -org.ostelco.pseudonym.PseudonymModule \ No newline at end of file diff --git a/pseudonym-server/src/main/resources/META-INF/services/org.ostelco.prime.pseudonymizer.PseudonymizerService b/pseudonym-server/src/main/resources/META-INF/services/org.ostelco.prime.pseudonymizer.PseudonymizerService deleted file mode 100644 index e54e12f9b..000000000 --- a/pseudonym-server/src/main/resources/META-INF/services/org.ostelco.prime.pseudonymizer.PseudonymizerService +++ /dev/null @@ -1 +0,0 @@ -org.ostelco.pseudonym.service.PseudonymServiceImpl \ No newline at end of file diff --git a/pseudonym-server/src/test/kotlin/org/ostelco/pseudonym/DateUtilsTest.kt b/pseudonym-server/src/test/kotlin/org/ostelco/pseudonym/DateUtilsTest.kt deleted file mode 100644 index e0711998c..000000000 --- a/pseudonym-server/src/test/kotlin/org/ostelco/pseudonym/DateUtilsTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.ostelco.pseudonym - -import org.junit.Test -import org.ostelco.pseudonym.utils.WeeklyBounds -import kotlin.test.assertEquals - -/** - * Class for unit testing DateUtils. - */ -class DateUtilsTest { - private val dateBounds = WeeklyBounds() - /** - * Test the most common use case, find next start period - */ - @Test - fun testGetNextPeriodStart() { - // GMT: Saturday, May 12, 2018 11:59:59.999 PM - val timestamp = 1526169599999 - // GMT: Monday, May 14, 2018 12:00:00 AM - val expectedNextTimestamp = 1526256000000 - val nextTimestamp = dateBounds.getNextPeriodStart(timestamp) - print("Expected Timestamp $expectedNextTimestamp Next timestamp $nextTimestamp"); - assertEquals(expectedNextTimestamp, nextTimestamp) - } - /** - * Test what happens when input is in last week of the year - */ - @Test - fun testGetNextPeriodAtYearEnd() { - // GMT: Monday, December 31, 2018 11:59:59 PM - val timestamp = 1546300799000 - // GMT: Monday, January 7, 2019 12:00:00 AM - val expectedNextTimestamp = 1546819200000 - val nextTimestamp = dateBounds.getNextPeriodStart(timestamp) - assertEquals(expectedNextTimestamp, nextTimestamp) - } -} diff --git a/pseudonym-server/src/test/kotlin/org/ostelco/pseudonym/PseudonymResourceTest.kt b/pseudonym-server/src/test/kotlin/org/ostelco/pseudonym/PseudonymResourceTest.kt deleted file mode 100644 index 288afdd53..000000000 --- a/pseudonym-server/src/test/kotlin/org/ostelco/pseudonym/PseudonymResourceTest.kt +++ /dev/null @@ -1,266 +0,0 @@ -package org.ostelco.pseudonym - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.google.cloud.bigquery.BigQuery -import io.dropwizard.testing.junit.ResourceTestRule -import org.junit.ClassRule -import org.junit.Test -import org.mockito.Mockito.mock -import org.ostelco.prime.model.ActivePseudonyms -import org.ostelco.prime.model.PseudonymEntity -import org.ostelco.pseudonym.resources.PseudonymResource -import org.ostelco.pseudonym.service.PseudonymizerServiceSingleton -import javax.ws.rs.core.Response.Status -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * Class for unit testing PseudonymResource. - */ -class PseudonymResourceTest { - - private val pathForGet = "/pseudonym/get" - private val pathForCurrent = "/pseudonym/current" - private val pathForActive = "/pseudonym/active" - private val pathForFind = "/pseudonym/find" - private val pathForDelete = "/pseudonym/delete" - private val testMsisdn1 = "4790303333" - private val testMsisdn2 = "4790309999" - - companion object { - - init { - ConfigRegistry.config = PseudonymServerConfig() - .apply { this.datastoreType = "inmemory-emulator" } - PseudonymizerServiceSingleton.init(env = null, bq = mock(BigQuery::class.java)) - } - - @ClassRule - @JvmField - val resources: ResourceTestRule? = ResourceTestRule.builder() - .addResource(PseudonymResource()) - .build() - } - - private val mapper = jacksonObjectMapper() - - /** - * Test what happens when parameter is not given - */ - @Test - fun testPseudonymResourceForMissingParameter() { - - val statusCode = resources - ?.target("$pathForCurrent/") - ?.request() - ?.get() - ?.status ?: -1 - - assertEquals(Status.NOT_FOUND.statusCode, statusCode) - } - - /** - * Test a normal request will all parameters - */ - @Test - fun testCurrentPseudonym() { - val statusCode = resources - ?.target("$pathForCurrent/$testMsisdn1") - ?.request() - ?.get() - ?.status ?: -1 - - assertEquals(Status.OK.statusCode, statusCode) - } - - /** - * Test get pseudonym for a timestamp - */ - @Test - fun testGetPseudonym() { - - lateinit var pseudonymEntity:PseudonymEntity - run { - val result = resources - ?.target("$pathForCurrent/$testMsisdn1") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - pseudonymEntity = mapper.readValue(json) - assertEquals(testMsisdn1, pseudonymEntity.sourceId) - } - - run { - val result = resources - ?.target("$pathForGet/$testMsisdn1/${pseudonymEntity.start}") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - val pseudonymEntity2 = mapper.readValue(json) - assertEquals(pseudonymEntity.pseudonym, pseudonymEntity2.pseudonym) - } - } - - /** - * Test get pseudonym for a timestamp - */ - @Test - fun testActivePseudonyms() { - - lateinit var pseudonymEntity:PseudonymEntity - run { - val result = resources - ?.target("$pathForCurrent/$testMsisdn1") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - pseudonymEntity = mapper.readValue(json) - assertEquals(testMsisdn1, pseudonymEntity.sourceId) - } - - run { - val result = resources - ?.target("$pathForActive/$testMsisdn1") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - // This is how the client will recieve the output. - val mapOfPseudonyms: Map = mapper.readValue(json) - val current = mapOfPseudonyms["current"] - val next = mapOfPseudonyms["next"] - assertNotNull(current) - assertNotNull(next) - if (current != null && next != null) { - assertEquals(current.pseudonym, pseudonymEntity.pseudonym) - assertEquals(current.end + 1, next.start) - } - } - } - - /** - * Test get pseudonym for a timestamp - */ - @Test - fun testActivePseudonymUsingModel() { - - lateinit var pseudonymEntity:PseudonymEntity - run { - val result = resources - ?.target("$pathForCurrent/$testMsisdn1") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - pseudonymEntity = mapper.readValue(json) - assertEquals(testMsisdn1, pseudonymEntity.sourceId) - } - - run { - val result = resources - ?.target("$pathForActive/$testMsisdn1") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - val active = mapper.readValue(json) - assertEquals(active.current.pseudonym, pseudonymEntity.pseudonym) - assertEquals(active.current.end + 1, active.next.start) - } - } - - /** - * Test a finding a pseudonym - */ - @Test - fun testFindPseudonym() { - - lateinit var pseudonymEntity:PseudonymEntity - run { - val result = resources - ?.target("$pathForCurrent/$testMsisdn1") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - pseudonymEntity = mapper.readValue(json) - assertEquals(testMsisdn1, pseudonymEntity.sourceId) - } - - run { - val result = resources - ?.target("$pathForFind/${pseudonymEntity.pseudonym}") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - val pseudonymEntity2 = mapper.readValue(json) - assertEquals(testMsisdn1, pseudonymEntity2.sourceId) - } - } - - /** - * Test deleting all pseudonyms for a msisdn - */ - @Test - fun testDeletePseudonym() { - lateinit var pseudonymEntity:PseudonymEntity - run { - val result = resources - ?.target("$pathForCurrent/$testMsisdn2") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - pseudonymEntity = mapper.readValue(json) - assertEquals(testMsisdn2, pseudonymEntity.sourceId) - } - - run { - val result = resources - ?.target("$pathForDelete/$testMsisdn2") - ?.request() - ?.delete() - assertNotNull(result) - if (result == null) return - assertEquals(Status.OK.statusCode, result.status) - val json = result.readEntity(String::class.java) - val countMap = mapper.readValue>(json) - val count = countMap["count"] ?: -1 - assertTrue(count >= 1) - } - - run { - val result = resources - ?.target("$pathForFind/${pseudonymEntity.pseudonym}") - ?.request() - ?.get() - assertNotNull(result) - if (result == null) return - assertEquals(Status.NOT_FOUND.statusCode, result.status) - } - } -} \ No newline at end of file diff --git a/run-full-regression-test.sh b/run-full-regression-test.sh new file mode 100755 index 000000000..265e8016c --- /dev/null +++ b/run-full-regression-test.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +## +## Utility script for running regression tests on a developer workstation. +## It is an automation of the procedure described in the docs/TEST.md file. +## It is a very good idea to run this script frequently, and at least before +## committing something in a PR. +## + + +DEPS="gradle openssl" +for dep in $DEPS ; do + if [[ -z "$(which $dep)" ]] ; then + echo "$0 ERROR: Missing dependency $dep" + exit 1 + fi +done + +# +# Check for the presence of the GCP service account file - (PSA) prime-service-account.json +# + +PSA_DIRS=$(grep -i prime-service-account.json $(find . -name '.gitignore') | awk -F: '{print $1}' | sort | uniq | sed 's/.gitignore//g') + +for PSA_DIR in $PSA_DIRS ; do + PSA_FILE="${PSA_DIR}prime-service-account.json" + if [[ ! -f "$PSA_FILE" ]] ; then + echo "$0 ERROR: Expected to find $PSA_FILE, but didn't. Cannot run regression tests." + exit 1 + fi +done + + +if [[ -z "$STRIPE_API_KEY" ]] ; then + echo "$0 ERROR: STRIPE_API_KEY is not set" + exit 0 +fi + + +# +# If necessary, create self-signed certificate for nginx with domain +# `ocs.dev.ostelco.org` and place them at following location: +# * In `certs/ocs.dev.ostelco.org`, keep `nginx.key` and `nginx.cert`. +# * In `ocsgw/cert`, keep `ocs.cert`. +# + +if [[ ! -f "ocsgw/cert/ocs.crt" ]] ; then + (cd certs/ocs.dev.ostelco.org ; + openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./nginx.key -out ./nginx.crt -subj '/CN=ocs.dev.ostelco.org' ; + cp nginx.crt ../../ocsgw/cert/ocs.crt) +fi + + +# +# If necessary, Create self-signed certificate for nginx with domain +# as `metrics.dev.ostelco.org` and place them at following location: +# * In `certs/metrics.dev.ostelco.org`, keep `nginx.key` and `nginx.cert`. +# In `ocsgw/cert`, keep `metrics.cert`. +# + +if [[ ! -f "ocsgw/cert/metrics.crt" ]] ; then + (cd certs/metrics.dev.ostelco.org; + openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./nginx.key -out ./nginx.crt -subj '/CN=metrics.dev.ostelco.org' ; + cp nginx.crt ../../ocsgw/cert/metrics.crt) +fi + + +# +# Then build (or not, we're using gradle) the whole system +# + +gradle build + +# +# Finally cd into the acceptance test directory, build everything +# from scratch and run the tests +# + +cd acceptance-tests +gradlew clean build +docker-compose up --build --abort-on-container-exit + + + diff --git a/sample-agent/apply_yaml.sh b/sample-agent/apply_yaml.sh index 8c88ff058..ab27c556d 100755 --- a/sample-agent/apply_yaml.sh +++ b/sample-agent/apply_yaml.sh @@ -1,7 +1,5 @@ #!/bin/bash -set -e - ### ### SEND PRE_WRITTEN YAML SCRIPT TO THE IMPORTER. ### @@ -42,7 +40,7 @@ fi # Check for dependencies being satisfied # -DEPENDENCIES="gcloud kubectl gsutil" +DEPENDENCIES="gcloud kubectl gsutil curl" for dep in $DEPENDENCIES ; do if [[ -z $(which $dep) ]] ; then @@ -55,9 +53,9 @@ done # sanity. # -PROJECT_ID=$(gcloud config get-value project) +GCP_PROJECT_ID=$(gcloud config get-value project) -if [[ -z "$PROJECT_ID" ]] ; then +if [[ -z "GCP_PROJECT_ID" ]] ; then echo "ERROR: Unknown google project ID" exit 1 fi @@ -74,8 +72,12 @@ fi ## working. ## +SEGMENT_IMPORTER_URL=http://127.0.0.1:8080/import/segments +OFFER_IMPORTER_URL=http://127.0.0.1:8080/import/offer + + EXPECTED_FROM_GET_TO_IMPORT='{"code":405,"message":"HTTP 405 Method Not Allowed"}' -RESULT_FROM_GET_PROBE="$(curl http://127.0.0.1:8080/import/offer 2>/dev/null)" +RESULT_FROM_GET_PROBE="$(curl $OFFER_IMPORTER_URL 2>/dev/null)" if [[ "$EXPECTED_FROM_GET_TO_IMPORT" != "$RESULT_FROM_GET_PROBE" ]] ; then echo "$0 ERROR: Did not get expected result when probing importer, bailing out" @@ -91,13 +93,11 @@ fi ## (assuming the kubectl port forwarding is enabled) if [[ "$IMPORT_TYPE" = "segments" ]] ; then - SEGMENT_IMPORTER_URL=http://127.0.0.1:8080/import/segments curl -X PUT -H "Content-type: text/vnd.yaml" --data-binary @$YAML_SCRIPTNAME $SEGMENT_IMPORTER_URL exit 0 fi if [[ "$IMPORT_TYPE" = "offer" ]] ; then - IMPORTER_URL=http://127.0.0.1:8080/import/offer - curl -X POST -H "Content-type: text/vnd.yaml" --data-binary @$YAML_SCRIPTNAME $IMPORTER_URL + curl -X POST -H "Content-type: text/vnd.yaml" --data-binary @$YAML_SCRIPTNAME $OFFER_IMPORTER_URL exit 0 fi diff --git a/sample-agent/generate-test-scripts.sh b/sample-agent/generate-test-scripts.sh index 108e13964..8cbbf21ae 100755 --- a/sample-agent/generate-test-scripts.sh +++ b/sample-agent/generate-test-scripts.sh @@ -29,7 +29,6 @@ SEGMENT_2="demoSegment2" SEGMENT_3="demoSegment3" - if [[ ! -d "$TARGET_DIR" ]] ; then echo "$0 ERROR: Target directory '$TARGET_DIR' does not exist or is not a directory" exit 1 @@ -42,7 +41,7 @@ createOffer: createProducts: - sku: 1GB_200NOK price: - amount: 200 + amount: 20000 currency: NOK properties: noOfBytes: 1_000_000_000 @@ -62,7 +61,7 @@ createOffer: createProducts: - sku: 2GB_200NOK price: - amount: 200 + amount: 20000 currency: NOK properties: noOfBytes: 2_000_000_000 @@ -81,11 +80,12 @@ createOffer: createProducts: - sku: 1GB_50NOK price: - amount: 50 + amount: 5000 currency: NOK properties: noOfBytes: 1_000_000_000 presentation: + offerDescription: Need more data? Get 1GB for the special price of 50 NOK isDefault: true offerLabel: Special offer priceLabel: 50 NOK @@ -98,10 +98,10 @@ cat > $TARGET_DIR/step1.yml < $TARGET_DIR/step2.yml < = HashMap() + + private fun getEncrypter(countryCode: String): ScanInfoEncrypt { + if (encrypters.containsKey(countryCode)) { + return encrypters[countryCode]!! + } else { + logger.info("Initializing ScanInfoEncrypt for country:${countryCode}") + val encrypt = ScanInfoEncrypt("${keysetFilePathPrefix}_${countryCode}", masterKeyUri) + encrypters.put(countryCode, encrypt) + return encrypt + } + } + + /** + * Save the scan information in cloud storage. + * Downloads images and create the zip file. The zip file is then + * encrypted with the keys for right buckets. + * - Saves the zip files in two locations, + * 1) -global//.zip.tk + * 2) -//.zip.tk + */ + override fun upsertVendorScanInformation(customerId: String, countryCode:String, vendorData: MultivaluedMap): Either { + return IO { + Either.monad().binding { + logger.info("Creating createVendorScanInformation for customerId = ${customerId}") + val vendorScanInformation = createVendorScanInformation(vendorData).bind() + val bucketName = storageBucket + logger.info("Generating Plain Zip data for customerId = ${customerId}") + val plainZipData = JumioHelper.generateZipFile(vendorScanInformation).bind() + logger.info("Encrypt for global customerId = ${customerId}") + val zipData = getEncrypter("global").encryptData(plainZipData) + if (bucketName.isNullOrEmpty()) { + val fileName = "${countryCode}_${vendorScanInformation.id}.zip.tk" + logger.info("No bucket set, saving file locally $fileName") + JumioHelper.saveLocalFile(fileName, zipData).bind() + } else { + val fileName = "${customerId}/${vendorScanInformation.id}.zip.tk" + val globalBucket = "${bucketName}-global" + val countryBucket = "${bucketName}-${countryCode.toLowerCase()}" + logger.info("Saving in cloud storage $globalBucket --> $fileName") + JumioHelper.uploadFileToCloudStorage(globalBucket, fileName, zipData).bind() + if (countryBucket != globalBucket) { + logger.info("Saving in cloud storage $countryBucket --> $fileName") + val localZipData = getEncrypter(countryCode).encryptData(plainZipData) + JumioHelper.uploadFileToCloudStorage(countryBucket, fileName, localZipData).bind() + } + } + saveScanMetaData(customerId, countryCode, vendorScanInformation).bind() + Unit + }.fix() + }.unsafeRunSync() + } + + override fun getExtendedStatusInformation(vendorData: MultivaluedMap): Map { + return JumioHelper.getExtendedStatusInformation(vendorData) + } + + private fun createVendorScanInformation(vendorData: MultivaluedMap): Either { + return JumioHelper.generateVendorScanInformation(vendorData, apiToken, apiSecret) + } + + private fun saveScanMetaData(customerId: String, countryCode:String, vendorScanInformation:VendorScanInformation): Either { + val keyString = "$customerId-${vendorScanInformation.id}" + try { + val key = keyFactory.newKey(keyString) + val entity = Entity.newBuilder(key) + .set(ScanMetadataEnum.ID.s, vendorScanInformation.id) + .set(ScanMetadataEnum.SCAN_REFERENCE.s, vendorScanInformation.scanReference) + .set(ScanMetadataEnum.COUNTRY_CODE.s, countryCode) + .set(ScanMetadataEnum.CUSTOMER_ID.s, customerId) + .set(ScanMetadataEnum.PROCESSED_TIME.s, Instant.now().toEpochMilli()) + .build() + datastore.add(entity) + logger.info("Saved ScanMetaData for customerId = $customerId key = ${keyString}") + } catch (e: DatastoreException) { + logger.error("Caught exception while storing the scan meta data", e) + return Either.left(NotCreatedError("ScanMetaData", keyString)) + } + return Unit.right() + } + + // Internal function used by unit test to check the encrypted zip file + internal fun __getVendorScanInformationFile(customerId: String, countryCode:String, scanId: String): Either { + return Either.right("${countryCode}_$scanId.zip.tk") + } + // Internal function used by unit test to check the scan meta data + internal fun __getScanMetaData(customerId: String, scanId: String): ScanMetadata? { + val keyString = "$customerId-$scanId" + try { + val key = keyFactory.newKey(keyString) + val entity = datastore.get(key) + return ScanMetadata( + id = entity.getString(ScanMetadataEnum.ID.s), + scanReference = entity.getString(ScanMetadataEnum.SCAN_REFERENCE.s), + countryCode = entity.getString(ScanMetadataEnum.COUNTRY_CODE.s), + customerId = entity.getString(ScanMetadataEnum.CUSTOMER_ID.s), + processedTime = entity.getLong(ScanMetadataEnum.PROCESSED_TIME.s) + ) + } catch (e: DatastoreException) { + logger.error("Caught exception while retreiving scan meta data", e) + } + return null + } + + // Initialize the object, get all the environment variables and initialize the encrypter library. + fun init(env: Environment?, environmentVars: EnvironmentVars) { + TinkConfig.register() + keysetFilePathPrefix = ConfigRegistry.config.keysetFilePathPrefix + val storeType = ConfigRegistry.config.storeType + if (storeType != "emulator" && storeType != "inmemory-emulator") { + apiToken = environmentVars.getVar("JUMIO_API_TOKEN") + ?: throw Error("Missing environment variable JUMIO_API_TOKEN") + apiSecret = environmentVars.getVar("JUMIO_API_SECRET") + ?: throw Error("Missing environment variable JUMIO_API_SECRET") + storageBucket = environmentVars.getVar("SCANINFO_STORAGE_BUCKET") + ?: throw Error("Missing environment variable SCANINFO_STORAGE_BUCKET") + masterKeyUri = environmentVars.getVar("SCANINFO_MASTERKEY_URI") + ?: throw Error("Missing environment variable SCANINFO_MASTERKEY_URI") + } else { + // Don't throw error during local tests + apiToken = "" + apiSecret = "" + storageBucket = "" + masterKeyUri = null + } + initDatastore(env) + } + + fun cleanup() { + if (ConfigRegistry.config.storeType == "inmemory-emulator") { + // Stop the emulator after unit tests. + localDatastoreHelper.stop() + } + } + + // Integration testing helper for Datastore. + private fun initDatastore(env: Environment?) { + datastore = when (ConfigRegistry.config.storeType) { + "inmemory-emulator" -> { + logger.info("Starting with in-memory datastore emulator") + localDatastoreHelper = LocalDatastoreHelper.create(1.0) + localDatastoreHelper.start() + localDatastoreHelper.options + } + "emulator" -> { + // When prime running in GCP by hosted CI/CD, Datastore client library assumes it is running in + // production and ignore our instruction to connect to the datastore emulator. So, we are explicitly + // connecting to emulator + logger.info("Connecting to datastore emulator") + DatastoreOptions + .newBuilder() + .setHost("localhost:9090") + .setCredentials(NoCredentials.getInstance()) + .setTransportOptions(HttpTransportOptions.newBuilder().build()) + .build() + } + else -> { + logger.info("Created default instance of datastore client") + DatastoreOptions + .newBuilder() + .setNamespace(ConfigRegistry.config.namespace) + .build() + } + }.service + keyFactory = datastore.newKeyFactory().setKind(ScanMetadataEnum.KIND.s) + } +} + +/** + * A utility for downloading and creating the scan information for Jumio clients. + */ +object JumioHelper { + private val logger by getLogger() + /** + * Retrieves the contents of a file from a URL + */ + private fun downloadFileAsBlob(fileURL: String, username: String, password: String): Either> { + val url = URL(fileURL) + val httpConn = url.openConnection() as HttpURLConnection + val userpass = "$username:$password" + val authHeader = "Basic ${Base64.getEncoder().encodeToString(userpass.toByteArray())}" + httpConn.setRequestProperty("Authorization", authHeader) + + try { + val responseCode = httpConn.responseCode + // always check HTTP response code first + if (responseCode != HttpURLConnection.HTTP_OK) { + val statusMessage = "$responseCode: ${httpConn.responseMessage}" + logger.error("Failed to download $fileURL $statusMessage") + return Either.left(FileDownloadError(fileURL, statusMessage)) + } + val contentType = httpConn.contentType + val inputStream = httpConn.inputStream + val fileData = Blob.copyFrom(inputStream) + inputStream.close() + return Either.right(Pair(fileData, contentType)) + } catch (e: IOException) { + val statusMessage = "IOException: $e" + logger.error("Failed to download $fileURL $statusMessage") + return Either.left(FileDownloadError(fileURL, statusMessage)) + } finally { + httpConn.disconnect() + } + } + + private fun isJSONArray(jsonData: String): Boolean { + try { + val mapper = ObjectMapper() + return mapper.readTree(jsonData).isArray + } catch (e: IOException) { + return false + } + } + + private fun flattenList(list: List): List { + try { + if (list.size > 1) { + return list //already flattened. + } + val jsonData:String = list[0] + if (isJSONArray(jsonData)) { + return ObjectMapper().readValue(jsonData) + } + } catch (e: IOException) { + logger.error("Cannot flattenList Json Data $list", e) + } + return list; + } + + /** + * Creates the VendorScanInformation from the input map. + * - Downloads all the required images + */ + fun generateVendorScanInformation(vendorData: MultivaluedMap, apiToken: String, apiSecret: String): Either { + var images:MutableMap = mutableMapOf() + + val scanId: String = vendorData.getFirst(JumioScanData.SCAN_ID.s) + val scanReference: String = vendorData.getFirst(JumioScanData.JUMIO_SCAN_ID.s) + val scanDetails: String = ObjectMapper().writeValueAsString(vendorData) + val scanImageUrl: String? = vendorData.getFirst(JumioScanData.SCAN_IMAGE.s) + val scanImageBacksideUrl: String? = vendorData.getFirst(JumioScanData.SCAN_IMAGE_BACKSIDE.s) + val scanImageFaceUrl: String? = vendorData.getFirst(JumioScanData.SCAN_IMAGE_FACE.s) + val scanlivenessImagesUrl: List? = vendorData[JumioScanData.SCAN_LIVENESS_IMAGES.s] + + return IO { + Either.monad().binding { + var result: Pair + if (scanImageUrl != null) { + logger.info("Downloading scan image: $scanImageUrl") + result = downloadFileAsBlob(scanImageUrl, apiToken, apiSecret).bind() + val filename = "id.${getFileExtFromType(result.second)}" + images.put(filename, result.first) + } + if (scanImageBacksideUrl != null) { + logger.info("Downloading scan image back: $scanImageBacksideUrl") + result = downloadFileAsBlob(scanImageBacksideUrl, apiToken, apiSecret).bind() + val filename = "id_backside.${getFileExtFromType(result.second)}" + images.put(filename, result.first) + } + if (scanImageFaceUrl != null) { + logger.info("Downloading Face Image: $scanImageFaceUrl") + result = downloadFileAsBlob(scanImageFaceUrl, apiToken, apiSecret).bind() + val filename = "face.${getFileExtFromType(result.second)}" + images.put(filename, result.first) + } + if(scanlivenessImagesUrl != null) { + val urls = scanlivenessImagesUrl.toMutableList() + urls.sort() // The url list is not in sequence + val flattenedList = flattenList(urls) + var imageIndex = 0 + for (imageUrl in flattenedList) { + logger.info("Downloading Liveness image: $imageUrl") + result = downloadFileAsBlob(imageUrl, apiToken, apiSecret).bind() + val filename = "liveness-${++imageIndex}.${getFileExtFromType(result.second)}" + images.put(filename, result.first) + } + } + VendorScanInformation(scanId, scanReference, scanDetails, images) + }.fix() + }.unsafeRunSync() + } + + private fun toRegularMap(jsonData: String?): Map? { + try { + if (jsonData != null) { + return ObjectMapper().readValue(jsonData) + } + } catch (e: IOException) { + logger.error("Cannot parse Json Data: $jsonData") + } + return null + } + + /** + * Constructs extended status information from Jumio callback data. + */ + fun getExtendedStatusInformation(vendorData: MultivaluedMap): Map { + var extendedStatus = mutableMapOf() + val verificationStatus: String = vendorData.getFirst(JumioScanData.VERIFICATION_STATUS.s) + val identityVerificationData: String? = vendorData.getFirst(JumioScanData.IDENTITY_VERIFICATION.s) + + extendedStatus.putIfAbsent(JumioScanData.VERIFICATION_STATUS.s, verificationStatus) + if (verificationStatus.toUpperCase() == JumioScanData.APPROVED_VERIFIED.s) { + val identityVerification = toRegularMap(identityVerificationData) + if (identityVerification == null) { + extendedStatus.putIfAbsent(JumioScanData.REJECT_REASON.s, JumioScanData.PRIME_MISSING_IDENTITY_VERIFICATION.s) + } else { + // identityVerification field is present + val similarity = identityVerification[JumioScanData.SIMILARITY.s] + val validity = identityVerification[JumioScanData.VALIDITY.s] + val reason = identityVerification[JumioScanData.REASON.s] + if (similarity == null || validity == null) { + // Similarity or Validity field is not present + extendedStatus.putIfAbsent(JumioScanData.IDENTITY_VERIFICATION.s, JumioScanData.PRIME_IDENTITY_VERIFICATION_FAILED.s) + } else { + if (similarity.toUpperCase() == JumioScanData.MATCH.s && validity.toUpperCase() == JumioScanData.TRUE.s) { + // This verification is a success + extendedStatus.putIfAbsent(JumioScanData.IDENTITY_VERIFICATION.s, JumioScanData.PRIME_IDENTITY_VALID_SIMILAR.s) + } else if (similarity.toUpperCase() != JumioScanData.MATCH.s) { + // The document and photo doesn't match + extendedStatus.putIfAbsent(JumioScanData.REJECT_REASON.s, similarity) + } else if (validity.toUpperCase() != JumioScanData.TRUE.s) { + // The photo is not valid. + extendedStatus.putIfAbsent(JumioScanData.REJECT_REASON.s, reason + ?: JumioScanData.PRIME_MISSING_IDENTITY_REASON.s) + } + } + } + } + return extendedStatus + } + /** + * Deletes the scan information from Jumio database. + */ + fun deleteScanInformation(vendorScanId: String, baserUrl:String, username: String, password: String): Either { + val url = URL("$baserUrl/$vendorScanId") + val httpConn = url.openConnection() as HttpURLConnection + val userpass = "$username:$password" + val authHeader = "Basic ${Base64.getEncoder().encodeToString(userpass.toByteArray())}" + httpConn.setRequestProperty("Authorization", authHeader) + httpConn.setRequestProperty("Accept", "application/json") + httpConn.setRequestProperty("User-Agent", "ScanInformationStore") + httpConn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + httpConn.doOutput = true; + httpConn.setRequestMethod("DELETE"); + + try { + val responseCode = httpConn.responseCode + // always check HTTP response code first + if (responseCode != HttpURLConnection.HTTP_OK) { + val statusMessage = "$responseCode: ${httpConn.responseMessage}" + return Either.left(FileDeleteError(url.toString(), statusMessage)); + } + return Unit.right() + } catch (e: IOException) { + val statusMessage = "IOException: $e" + return Either.left(FileDeleteError(url.toString(), statusMessage)) + } finally { + httpConn.disconnect() + } + } + /** + * Creates the zip file from VendorScanInformation. + */ + fun generateZipFile(vendorData: VendorScanInformation): Either { + val outputStream = ByteArrayOutputStream() + val zos = ZipOutputStream(BufferedOutputStream(outputStream)) + + try { + zos.putNextEntry(ZipEntry("postdata.json")) + zos.write(vendorData.details.toByteArray()) + zos.closeEntry() + // Append all images + if (vendorData.images != null) { + vendorData.images?.map { (filename, data) -> + zos.putNextEntry(ZipEntry(filename)) + zos.write(data.toByteArray()) + zos.closeEntry() + Unit + } + } + zos.finish() + } catch (e: IOException) { + return Either.left(NotCreatedError(VendorScanData.TYPE_NAME.s, vendorData.id)) + } finally { + zos.close() + } + return Either.right(outputStream.toByteArray()) + } + + /** + * Creates the file extension from mime-type. + */ + private fun getFileExtFromType(mimeType: String): String { + val idx = mimeType.lastIndexOf("/") + if (idx == -1) { + return mimeType + } else { + return mimeType.drop(idx + 1) + } + } + + + /** + * Upload the byte array to the given cloud storage object. + */ + fun uploadFileToCloudStorage(bucket: String, fileName: String, data: ByteArray): Either { + val storage = StorageOptions.getDefaultInstance().getService() + val blobId = BlobId.of(bucket, fileName) + val blobInfo = BlobInfo.newBuilder(blobId).setContentType("application/octet-stream").build() + var mediaLink: String + try { + val blob = storage.create(blobInfo, data) + mediaLink = blob.mediaLink + } catch (e: StorageException) { + return Either.left(NotCreatedError(VendorScanData.TYPE_NAME.s, "$bucket/$fileName")) + } + return Either.right(mediaLink) + } + + /** + * Save byte array as local file, used only for testing + */ + fun saveLocalFile(fileName: String, data: ByteArray): Either { + val fos = FileOutputStream(File(fileName)) + try { + fos.write(data) + fos.close() + } catch (e: IOException) { + return Either.left(NotCreatedError(VendorScanData.TYPE_NAME.s, "$fileName")) + } + return Either.right(fileName) + } + + + fun loadLocalZipFile(fileName: String): Either { + try { + val fis = FileInputStream(File(fileName)) + return Either.right(ZipInputStream(fis)) + } catch (e: FileNotFoundException) { + return Either.left(NotCreatedError(VendorScanData.TYPE_NAME.s, "$fileName")) + } + } + + @JvmStatic + fun main(args: Array) { + val fileURL = "https://jdbc.postgresql.org/download/postgresql-9.2-1002.jdbc4.jar" + try { + val ret = downloadFileAsBlob(fileURL, "", "") + println(ret) + } catch (ex: IOException) { + ex.printStackTrace() + } + __testDecryption() + } + + private fun __testDecryption() { + // The files created during the acceptance tests can be verified using this function + // Download encrypted files created in the root folder of prime docker image + // Find files by logging into the docker image `docker exec -ti prime bash` + // Copy files from docker image using `docker cp prime:/global_f1a6a509-7998-405c-b186-08983c91b422.zip.tk .` + // Replace the path for the input files in the method & run. + TinkConfig.register() + val file = File("global_f1a6a509-7998-405c-b186-08983c91b422.zip.tk") // File downloaded form docker image after AT + val fis = FileInputStream(file) + val data = ByteArray(file.length().toInt()) + fis.read(data) + fis.close() + val pvtKeysetFilename = "prime/config/test_keyset_pvt_cltxt" // The test private keys used in AT + val keysetHandle = CleartextKeysetHandle.read(JsonKeysetReader.withFile(File(pvtKeysetFilename))) + val hybridDecrypt = HybridDecryptFactory.getPrimitive(keysetHandle) + val decrypted = hybridDecrypt.decrypt(data, null) + saveLocalFile("decrypted.zip", decrypted) + } +} \ No newline at end of file diff --git a/scaninfo-datastore/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable b/scaninfo-datastore/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable new file mode 100644 index 000000000..8056fe23b --- /dev/null +++ b/scaninfo-datastore/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable @@ -0,0 +1 @@ +org.ostelco.prime.module.PrimeModule \ No newline at end of file diff --git a/scaninfo-datastore/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/scaninfo-datastore/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule new file mode 100644 index 000000000..5543faf4d --- /dev/null +++ b/scaninfo-datastore/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule @@ -0,0 +1 @@ +org.ostelco.prime.storage.scaninfo.ScanInfoModule \ No newline at end of file diff --git a/scaninfo-datastore/src/main/resources/META-INF/services/org.ostelco.prime.storage.ScanInformationStore b/scaninfo-datastore/src/main/resources/META-INF/services/org.ostelco.prime.storage.ScanInformationStore new file mode 100644 index 000000000..a15b098ae --- /dev/null +++ b/scaninfo-datastore/src/main/resources/META-INF/services/org.ostelco.prime.storage.ScanInformationStore @@ -0,0 +1 @@ +org.ostelco.prime.storage.scaninfo.ScanInfoStore \ No newline at end of file diff --git a/scaninfo-datastore/src/test/kotlin/org/ostelco/prime/storage/scaninfo/ScanInfoStoreTest.kt b/scaninfo-datastore/src/test/kotlin/org/ostelco/prime/storage/scaninfo/ScanInfoStoreTest.kt new file mode 100644 index 000000000..4dac92b22 --- /dev/null +++ b/scaninfo-datastore/src/test/kotlin/org/ostelco/prime/storage/scaninfo/ScanInfoStoreTest.kt @@ -0,0 +1,100 @@ +package org.ostelco.prime.storage.scaninfo + +import com.google.crypto.tink.CleartextKeysetHandle +import com.google.crypto.tink.JsonKeysetWriter +import com.google.crypto.tink.KeysetHandle +import com.google.crypto.tink.hybrid.HybridDecryptFactory +import com.google.crypto.tink.hybrid.HybridKeyTemplates +import org.junit.AfterClass +import org.junit.BeforeClass +import org.mockito.Mockito +import org.ostelco.prime.model.JumioScanData +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileInputStream +import java.time.Instant +import java.util.zip.ZipInputStream +import javax.ws.rs.core.MultivaluedHashMap +import javax.ws.rs.core.MultivaluedMap +import kotlin.test.* + +class ScanInfoStoreTest { + @BeforeTest + fun clear() { + + } + + @Test + fun `test - add check store`() { + val customerId= "test@example.com" + val vendorData: MultivaluedMap = MultivaluedHashMap() + val scanId = "scanid1" + val scanReference = "scanidref1" + val imgUrl = "https://www.gstatic.com/webp/gallery3/1.png" + val imgUrl2 = "https://www.gstatic.com/webp/gallery3/2.png" + vendorData.add(JumioScanData.SCAN_ID.s, scanId) + vendorData.add(JumioScanData.JUMIO_SCAN_ID.s, scanReference) + vendorData.add(JumioScanData.SCAN_IMAGE.s, imgUrl) + vendorData.add(JumioScanData.SCAN_IMAGE_BACKSIDE.s, imgUrl2) + vendorData.addAll(JumioScanData.SCAN_LIVENESS_IMAGES.s, listOf(imgUrl, imgUrl2)) + + ScanInformationStoreSingleton.upsertVendorScanInformation(customerId, "global", vendorData) + val savedFile = ScanInformationStoreSingleton.__getVendorScanInformationFile(customerId, "global", scanId) + assert(savedFile.isRight()) + savedFile.map { filename -> + val file = File(filename) + val fis = FileInputStream(file) + val data = ByteArray(file.length().toInt()) + fis.read(data) + fis.close() + + val hybridDecrypt = HybridDecryptFactory.getPrimitive(privateKeysetHandle) + val decrypted = hybridDecrypt.decrypt(data, null) + + val zip = ZipInputStream(ByteArrayInputStream(decrypted)) + val details = zip.nextEntry + assertEquals("postdata.json", details.name) + val image = zip.nextEntry + assertEquals("id.png", image.name) + val imageBackside = zip.nextEntry + assertEquals("id_backside.png", imageBackside.name) + File(filename).delete() + } + val scanMetadata = ScanInformationStoreSingleton.__getScanMetaData(customerId, scanId) + assertNotEquals(null, scanMetadata) + if (scanMetadata != null) { + assertEquals(scanReference, scanMetadata.scanReference) + assertEquals("global", scanMetadata.countryCode) + assertTrue(scanMetadata.processedTime <= Instant.now().toEpochMilli()) + } + } + + companion object { + private lateinit var privateKeysetHandle:KeysetHandle + + @JvmStatic + @BeforeClass + fun init() { + File("encrypt_key_global").delete() + val testEnvVars = Mockito.mock(EnvironmentVars::class.java) + Mockito.`when`(testEnvVars.getVar("JUMIO_API_TOKEN")).thenReturn("") + Mockito.`when`(testEnvVars.getVar("JUMIO_API_SECRET")).thenReturn("") + Mockito.`when`(testEnvVars.getVar("SCANINFO_STORAGE_BUCKET")).thenReturn("") + ConfigRegistry.config = ScanInfoConfig() + .apply { this.storeType = "inmemory-emulator" } + ScanInformationStoreSingleton.init(null, testEnvVars) + privateKeysetHandle = KeysetHandle.generateNew(HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM) + val publicKeysetHandle = privateKeysetHandle.publicKeysetHandle + val keysetFilename = "encrypt_key_global" + CleartextKeysetHandle.write(publicKeysetHandle, JsonKeysetWriter.withFile(File(keysetFilename))) + } + + @JvmStatic + @AfterClass + fun cleanup() { + File("encrypt_key_global").delete() + ScanInformationStoreSingleton.cleanup() + } + + } +} \ No newline at end of file diff --git a/scaninfo-shredder/.gitignore b/scaninfo-shredder/.gitignore new file mode 100644 index 000000000..57be5a48e --- /dev/null +++ b/scaninfo-shredder/.gitignore @@ -0,0 +1,35 @@ +### Eclipse ### +.checkstyle +.classpath +.metadata +.loadpath +.project +.settings/ + +### Gradle ### +/.gradle/ +/build/ + +### Intellij ### +.idea/ +*.iml +*.ipr +*.iws +out/ + +### Mac OSX + Windows ### +.DS_Store +Thumbs.db + +### Node ### +/node_modules/ +npm-debug.log + +### SublimeText + TextMate ### +*.sublime-workspace +*.sublime-project +*.tmproj +*.tmproject + +### Vim ### +*.sw[op] diff --git a/scaninfo-shredder/Dockerfile b/scaninfo-shredder/Dockerfile new file mode 100644 index 000000000..8d8015df4 --- /dev/null +++ b/scaninfo-shredder/Dockerfile @@ -0,0 +1,28 @@ +FROM azul/zulu-openjdk:11.0.1-11.2 + +LABEL maintainer="dev@redotter.sg" + +# +# Copy the files we need +# + +COPY script/start.sh /start.sh +COPY config/config.yaml /config/config.yaml +COPY build/libs/scaninfo-shredder-uber.jar /scaninfo-shredder.jar + +# +# Load, then dump the standard java classes into the +# image being built, to speed up java load time +# using Class Data Sharing. The "quit" command will +# simply quit the program after it's dumped the list of +# classes that should be cached. +# + +RUN ["java", "-Dfile.encoding=UTF-8", "-Xshare:on", "-Xshare:dump", "-jar", "/scaninfo-shredder.jar", "quit", "config/config.yaml"] + + +# +# Finally the actual entry point +# + +CMD ["/start.sh"] diff --git a/scaninfo-shredder/Dockerfile.test b/scaninfo-shredder/Dockerfile.test new file mode 100644 index 000000000..5932fa97e --- /dev/null +++ b/scaninfo-shredder/Dockerfile.test @@ -0,0 +1,29 @@ +FROM azul/zulu-openjdk:11.0.1-11.2 + +LABEL maintainer="dev@redotter.sg" + +# +# Copy the files we need +# + +COPY script/start.sh /start.sh +COPY config/prime-service-account.json /secret/prime-service-account.json +COPY config/config.yaml /config/config.yaml +COPY build/libs/bq-metrics-extractor-uber.jar /scaninfo-shredder.jar + +# +# Load, then dump the standard java classes into the +# image being built, to speed up java load time +# using Class Data Sharing. The "quit" command will +# simply quit the program after it's dumped the list of +# classes that should be cached. +# + +RUN ["java", "-Dfile.encoding=UTF-8", "-Xshare:on", "-Xshare:dump", "-jar", "/scaninfo-shredder.jar", "quit", "config/config.yaml"] + + +# +# Finally the actual entry point +# + +ENTRYPOINT ["/start.sh"] diff --git a/scaninfo-shredder/README.md b/scaninfo-shredder/README.md new file mode 100644 index 000000000..128cb3087 --- /dev/null +++ b/scaninfo-shredder/README.md @@ -0,0 +1,39 @@ +Scan Information Shredder +======= + +This module is a standalone, command-line launched dropwizard application +that will delete the Scan information stored in JUMIO data center. + +The component is as a docker component, and will then be periodically +run as a command line application, as a +[Kubernetes cron job](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/). + +The component is packaged as an individual docker artefact (details below), +and deployed as a cronjob (also described below). + +To run the program from the command line, which is useful when debugging and +necessary to know when constructing a Docker file, do this: + + java -jar /scaninfo-shredder.jar shred config/config.yaml + +Up on a successful eKYC scan, all the data gathered by JUMIO is encrypted and dumped to +a bucket in cloud storage by prime. This information is not immediately deleted from +JUMIO's databases. This cron job will wait for 2 weeks and then delete them from JUMIO. +This delete can be avoided by removing the datastore entry for that individual scan. + +How to build and deploy the cronjob manually +=== + +##Build and deploy the artifact: + +Build and deploy to dev cluster + + scaninfo-shredder/cronjob/deploy-dev-direct.sh + +Build and deploy to prod cluster + + scaninfo-shredder/cronjob/deploy-direct.sh + +## Display the cronjob status in kubernetes + + kubectl describe cronjob scaninfo-shredder diff --git a/scaninfo-shredder/build.gradle b/scaninfo-shredder/build.gradle new file mode 100644 index 000000000..bddc83273 --- /dev/null +++ b/scaninfo-shredder/build.gradle @@ -0,0 +1,39 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "application" + // Keeping version to 4.0.1 - https://github.com/johnrengelman/shadow/issues/425 + // This issue is NOT fixed in version > 4.0.1 including 5.0.0. + id "com.github.johnrengelman.shadow" version "4.0.1" +} + +version = "1.0.0" + +dependencies { + api project(':model') + + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinXCoroutinesVersion" + + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation "com.google.cloud:google-cloud-datastore:$googleCloudVersion" + + runtimeOnly "io.dropwizard:dropwizard-json-logging:$dropwizardVersion" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion" + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" + +} + +shadowJar { + mainClassName = 'org.ostelco.storage.scaninfo.shredder.ScanInfoShredderApplicationKt' + mergeServiceFiles() + classifier = "uber" + version = null +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/scaninfo-shredder/config/.gitignore b/scaninfo-shredder/config/.gitignore new file mode 100644 index 000000000..3b858adea --- /dev/null +++ b/scaninfo-shredder/config/.gitignore @@ -0,0 +1 @@ +prime-service-account.json \ No newline at end of file diff --git a/scaninfo-shredder/config/config.yaml b/scaninfo-shredder/config/config.yaml new file mode 100644 index 000000000..b4ca57daf --- /dev/null +++ b/scaninfo-shredder/config/config.yaml @@ -0,0 +1,14 @@ +logging: + level: INFO + loggers: + org.ostelco: DEBUG + appenders: + - type: console + layout: + type: json + customFieldNames: + level: severity + +deleteScan: true +deleteUrl: "https://netverify.com/api/netverify/v2/scans/" +namespace: ${DATASTORE_NAMESPACE:-""} diff --git a/scaninfo-shredder/cronjob/deploy-dev-direct.sh b/scaninfo-shredder/cronjob/deploy-dev-direct.sh new file mode 100755 index 000000000..182a6c08f --- /dev/null +++ b/scaninfo-shredder/cronjob/deploy-dev-direct.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -e + +if [ ! -f scaninfo-shredder/cronjob/deploy-dev-direct.sh ]; then + (>&2 echo "Run this script from project root dir (ostelco-core)") + exit 1 +fi + +kubectl config use-context $(kubectl config get-contexts --output name | grep dev-cluster) + +GCP_PROJECT_ID="$(gcloud config get-value project -q)" +SHREDDER_VERSION="$(gradle scaninfo-shredder:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" +SHORT_SHA="$(git log -1 --pretty=format:%h)" +TAG="${SHREDDER_VERSION}-${SHORT_SHA}-dev" + +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} +echo SHREDDER_VERSION=${SHREDDER_VERSION} +echo SHORT_SHA=${SHORT_SHA} +echo TAG=${TAG} + + +gradle scaninfo-shredder:clean scaninfo-shredder:build +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/scaninfo-shredder:${TAG} scaninfo-shredder +docker push eu.gcr.io/${GCP_PROJECT_ID}/scaninfo-shredder:${TAG} + +echo "Deploying scaninfo-shredder to GKE" + +sed -e 's/SHREDDER_VERSION/'"${TAG}"'/g; s/GCP_PROJECT_ID/'"${GCP_PROJECT_ID}"'/g' scaninfo-shredder/cronjob/shredder-dev.yaml | kubectl apply -f - diff --git a/scaninfo-shredder/cronjob/deploy-direct.sh b/scaninfo-shredder/cronjob/deploy-direct.sh new file mode 100755 index 000000000..9b1c43b8e --- /dev/null +++ b/scaninfo-shredder/cronjob/deploy-direct.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -e + +if [ ! -f scaninfo-shredder/cronjob/deploy-direct.sh ]; then + (>&2 echo "Run this script from project root dir (ostelco-core)") + exit 1 +fi + +kubectl config use-context $(kubectl config get-contexts --output name | grep private-cluster) + +GCP_PROJECT_ID="$(gcloud config get-value project -q)" +SHREDDER_VERSION="$(gradle scaninfo-shredder:properties -q | grep "version:" | awk '{print $2}' | tr -d '[:space:]')" +SHORT_SHA="$(git log -1 --pretty=format:%h)" +TAG="${SHREDDER_VERSION}-${SHORT_SHA}" + +echo GCP_PROJECT_ID=${GCP_PROJECT_ID} +echo SHREDDER_VERSION=${SHREDDER_VERSION} +echo SHORT_SHA=${SHORT_SHA} +echo TAG=${TAG} + + +gradle scaninfo-shredder:clean scaninfo-shredder:build +docker build -t eu.gcr.io/${GCP_PROJECT_ID}/scaninfo-shredder:${TAG} scaninfo-shredder +docker push eu.gcr.io/${GCP_PROJECT_ID}/scaninfo-shredder:${TAG} + +echo "Deploying scaninfo-shredder to GKE" + +sed -e 's/SHREDDER_VERSION/'"${TAG}"'/g; s/GCP_PROJECT_ID/'"${GCP_PROJECT_ID}"'/g' scaninfo-shredder/cronjob/shredder.yaml | kubectl apply -f - diff --git a/scaninfo-shredder/cronjob/shredder-dev.yaml b/scaninfo-shredder/cronjob/shredder-dev.yaml new file mode 100644 index 000000000..58352e825 --- /dev/null +++ b/scaninfo-shredder/cronjob/shredder-dev.yaml @@ -0,0 +1,30 @@ +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: scaninfo-shredder +spec: + schedule: "*/30 * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: scaninfo-shredder + image: eu.gcr.io/GCP_PROJECT_ID/scaninfo-shredder:SHREDDER_VERSION + imagePullPolicy: Always + env: + - name: DATASET_PROJECT + value: GCP_PROJECT_ID + - name: DATASTORE_NAMESPACE + value: dev + - name: JUMIO_API_TOKEN + valueFrom: + secretKeyRef: + name: jumio-secrets + key: apiToken + - name: JUMIO_API_SECRET + valueFrom: + secretKeyRef: + name: jumio-secrets + key: apiSecret + restartPolicy: Never diff --git a/scaninfo-shredder/cronjob/shredder.yaml b/scaninfo-shredder/cronjob/shredder.yaml new file mode 100644 index 000000000..28f7bef81 --- /dev/null +++ b/scaninfo-shredder/cronjob/shredder.yaml @@ -0,0 +1,28 @@ +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: scaninfo-shredder +spec: + schedule: "*/30 * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: scaninfo-shredder + image: eu.gcr.io/GCP_PROJECT_ID/scaninfo-shredder:SHREDDER_VERSION + imagePullPolicy: Always + env: + - name: DATASET_PROJECT + value: GCP_PROJECT_ID + - name: JUMIO_API_TOKEN + valueFrom: + secretKeyRef: + name: jumio-secrets + key: apiToken + - name: JUMIO_API_SECRET + valueFrom: + secretKeyRef: + name: jumio-secrets + key: apiSecret + restartPolicy: Never diff --git a/scaninfo-shredder/script/start.sh b/scaninfo-shredder/script/start.sh new file mode 100755 index 000000000..56f4b2997 --- /dev/null +++ b/scaninfo-shredder/script/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash -x + +# Start app +exec java \ + -Dfile.encoding=UTF-8 \ + -Xshare:on \ + -jar /scaninfo-shredder.jar shred config/config.yaml diff --git a/scaninfo-shredder/src/main/kotlin/org/ostelco/storage/scaninfo/shredder/ScanInfoShredderApplication.kt b/scaninfo-shredder/src/main/kotlin/org/ostelco/storage/scaninfo/shredder/ScanInfoShredderApplication.kt new file mode 100644 index 000000000..88eea11f3 --- /dev/null +++ b/scaninfo-shredder/src/main/kotlin/org/ostelco/storage/scaninfo/shredder/ScanInfoShredderApplication.kt @@ -0,0 +1,327 @@ +package org.ostelco.storage.scaninfo.shredder + + +import com.google.cloud.NoCredentials +import com.google.cloud.datastore.Cursor +import com.google.cloud.datastore.Datastore +import com.google.cloud.datastore.DatastoreException +import com.google.cloud.datastore.DatastoreOptions +import com.google.cloud.datastore.Entity +import com.google.cloud.datastore.KeyFactory +import com.google.cloud.datastore.Query +import com.google.cloud.datastore.QueryResults +import com.google.cloud.datastore.StructuredQuery +import com.google.cloud.datastore.StructuredQuery.OrderBy +import com.google.cloud.datastore.testing.LocalDatastoreHelper +import com.google.cloud.http.HttpTransportOptions +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.cli.ConfiguredCommand +import io.dropwizard.configuration.EnvironmentVariableSubstitutor +import io.dropwizard.configuration.SubstitutingSourceProvider +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import net.sourceforge.argparse4j.inf.Namespace +import org.ostelco.prime.model.ScanMetadata +import org.ostelco.prime.model.ScanMetadataEnum +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.time.Instant +import java.util.* + + +/** + * Main entry point, invoke dropwizard application. + */ +fun main(args: Array) = ScanInfoShredderApplication().run(*args) + +/** + * The configuration for Scan Information Shredder module. + */ +class ScanInfoShredderConfig : Configuration() { + var storeType = "default" + var namespace = "" + var deleteScan = false + var deleteUrl = "https://netverify.com/api/netverify/v2/scans/" +} + +/** + * Main entry point to the scaninfo-shredder API server. + */ +private class ScanInfoShredderApplication : Application() { + + override fun initialize(bootstrap: Bootstrap) { + // Enable variable substitution with environment variables + bootstrap.configurationSourceProvider = SubstitutingSourceProvider( + bootstrap.configurationSourceProvider, + EnvironmentVariableSubstitutor(false)) + bootstrap.addCommand(ShredScans()) + bootstrap.addCommand(CacheJars()) + } + + override fun run( + configuration: ScanInfoShredderConfig, + environment: Environment) { + } +} + +/** + * Helper class for getting environment variables. + * Introduced to help testing. + */ +open class EnvironmentVars { + /** + * Retrieve the value of the environment variable. + */ + open fun getVar(name: String): String? = System.getenv(name) +} + +/** + * Thrown when something really bad is detected and it's necessary to terminate + * execution immediately. No cleanup of anything will be done. + */ +private class ScanInfoShredderException(message: String) : RuntimeException(message) + + +/** + * Adapter class that will delete Scan Information from Jumio. + */ +internal class ScanInfoShredder(private val config: ScanInfoShredderConfig) { + + private val logger: Logger = LoggerFactory.getLogger(ScanInfoShredder::class.java) + + val expiryDuration = 1209600000 // 2 Weeks in Milliseconds + + internal lateinit var datastore: Datastore + internal lateinit var keyFactory: KeyFactory + private lateinit var filter: StructuredQuery.Filter + + // Used by unit tests + private lateinit var localDatastoreHelper: LocalDatastoreHelper + + /* Generated by Jumio and can be obtained from the console. */ + private lateinit var apiToken: String + private lateinit var apiSecret: String + + // Initialize the object, get all the environment variables and initialize the encrypter library. + fun init(environmentVars: EnvironmentVars) { + val storeType = config.storeType + if (storeType != "emulator" && storeType != "inmemory-emulator") { + apiToken = environmentVars.getVar("JUMIO_API_TOKEN") + ?: throw Error("Missing environment variable JUMIO_API_TOKEN") + apiSecret = environmentVars.getVar("JUMIO_API_SECRET") + ?: throw Error("Missing environment variable JUMIO_API_SECRET") + } else { + // Don't throw error during local tests + apiToken = "" + apiSecret = "" + } + logger.info("Config storeType = ${config.storeType}, namespace = ${config.namespace}, deleteScan = ${config.deleteScan} deleteUrl = ${config.deleteUrl} ") + initDatastore() + } + + fun cleanup() { + if (config.storeType == "inmemory-emulator") { + // Stop the emulator after unit tests. + localDatastoreHelper.stop() + } + } + + // Integration testing helper for Datastore. + private fun initDatastore() { + datastore = when (config.storeType) { + "inmemory-emulator" -> { + logger.info("Starting with in-memory datastore emulator") + localDatastoreHelper = LocalDatastoreHelper.create(1.0) + localDatastoreHelper.start() + localDatastoreHelper.options + } + "emulator" -> { + // When prime running in GCP by hosted CI/CD, Datastore client library assumes it is running in + // production and ignore our instruction to connect to the datastore emulator. So, we are explicitly + // connecting to emulator + logger.info("Connecting to datastore emulator") + DatastoreOptions + .newBuilder() + .setHost("localhost:9090") + .setCredentials(NoCredentials.getInstance()) + .setTransportOptions(HttpTransportOptions.newBuilder().build()) + .build() + } + else -> { + var optionsBuilder = DatastoreOptions.newBuilder() + if (!config.namespace.isEmpty()) { + optionsBuilder = optionsBuilder.setNamespace(config.namespace) + } + logger.info("Created default instance of datastore client") + optionsBuilder.setNamespace(config.namespace).build() + } + }.service + keyFactory = datastore.newKeyFactory().setKind(ScanMetadataEnum.KIND.s) + val expiryTime: Long = Instant.now().toEpochMilli() - expiryDuration + filter = StructuredQuery.PropertyFilter.le(ScanMetadataEnum.PROCESSED_TIME.s, expiryTime) + } + + /** + * Deletes the scan information from Jumio database. + */ + private fun deleteScanInformation(vendorScanId: String, baserUrl: String, username: String, password: String): Boolean { + val seperator: String = if (baserUrl.endsWith("/")) "" else "/" + val url = URL("$baserUrl$seperator$vendorScanId") + val httpConn = url.openConnection() as HttpURLConnection + val userpass = "$username:$password" + val authHeader = "Basic ${Base64.getEncoder().encodeToString(userpass.toByteArray())}" + httpConn.setRequestProperty("Authorization", authHeader) + httpConn.setRequestProperty("Accept", "application/json") + httpConn.setRequestProperty("User-Agent", "ScanInformationStore") + //httpConn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + httpConn.requestMethod = "DELETE" + httpConn.doOutput = true + + try { + val responseCode = httpConn.responseCode + // always check HTTP response code first + if (responseCode != HttpURLConnection.HTTP_OK) { + val statusMessage = "$responseCode: ${httpConn.responseMessage}" + logger.error("Failed to delete $url $statusMessage") + return false + } else { + logger.info("Deleted $url") + } + } catch (e: IOException) { + logger.error("Caught exception while trying to delete scan", e) + return false + } finally { + httpConn.disconnect() + } + return true + } + + private fun entitiesToScanMetadata(resultList: QueryResults): List { + val resultScans = ArrayList() + while (resultList.hasNext()) { + val entity = resultList.next() + val metadata = ScanMetadata( + id = entity.getString(ScanMetadataEnum.ID.s), + scanReference = entity.getString(ScanMetadataEnum.SCAN_REFERENCE.s), + countryCode = entity.getString(ScanMetadataEnum.COUNTRY_CODE.s), + customerId = entity.getString(ScanMetadataEnum.CUSTOMER_ID.s), + processedTime = entity.getLong(ScanMetadataEnum.PROCESSED_TIME.s) + ) + resultScans.add(metadata) + } + return resultScans + } + + private fun listScans(startCursorString: String?): Pair, String?> { + try { + var startCursor: Cursor? = null + if (startCursorString != null && startCursorString != "") { + startCursor = Cursor.fromUrlSafe(startCursorString) // Where we left off + } + val query = Query.newEntityQueryBuilder() // Build the Query + .setKind(ScanMetadataEnum.KIND.s) // We only care about ScanMetadata + .setLimit(100) // Only process 100 at a time + .setStartCursor(startCursor) // Where we left off + .setFilter(filter) // Which are expired + .setOrderBy(OrderBy.asc(ScanMetadataEnum.PROCESSED_TIME.s)) // Sorted by "processedTime" + .build() + val resultList = datastore.run(query) + val resultScans = entitiesToScanMetadata(resultList) // Retrieve and convert Entities + val cursor = resultList.cursorAfter // Where to start next time + return if (resultScans.size == 100) { // Are we paging? Save Cursor + val cursorString = cursor!!.toUrlSafe() // Cursors are WebSafe + Pair(resultScans, cursorString) + } else { + Pair(resultScans, null) + } + } catch (e: DatastoreException) { + logger.error("Caught exception while scanning metadata", e) + return Pair(ArrayList(), null) + } + } + + // Deletes the scan metadata from datastore + private fun deleteScanMetadata(data: ScanMetadata): Boolean { + try { + val keyString = "${data.customerId}-${data.id}" + datastore.delete(keyFactory.newKey(keyString)) + logger.info("Deleted datastore record for $keyString") + } catch (e: DatastoreException) { + logger.error("Caught exception while scanning metadata", e) + return false + } + return true + } + + suspend fun shred(): Int { + var totalItems = 0 + logger.info("Querying Datastore for Scan which are expired") + val start = System.currentTimeMillis() + coroutineScope { + var startCursor: String? = null + do { + val scanResult = listScans(startCursor) + scanResult.first.forEach { + launch { + val infoDeleted = if (config.deleteScan) { + deleteScanInformation(it.scanReference, config.deleteUrl, apiToken, apiSecret) + } else { + logger.info("Delete disabled, skipping ${it.scanReference}") + true + } + if (infoDeleted) { + // Delete the datastore record. + deleteScanMetadata(it) + } + } + } + totalItems += scanResult.first.size + startCursor = scanResult.second + } while (startCursor != null) + } + // coroutineScope waits for all children to finish. + val end = System.currentTimeMillis() + logger.info("Queries finished in ${(end - start) / 1000} seconds") + return totalItems + } +} + +private class ShredScans : ConfiguredCommand( + "shred", + "Delete all Scans which are expired") { + override fun run(bootstrap: Bootstrap?, namespace: Namespace?, configuration: ScanInfoShredderConfig?) { + + if (configuration == null) { + throw ScanInfoShredderException("Configuration is null") + } + + + if (namespace == null) { + throw ScanInfoShredderException("Namespace from config is null") + } + + runBlocking { + val shredder = ScanInfoShredder(configuration) + shredder.init(EnvironmentVars()) + shredder.shred() + shredder.cleanup() + } + } +} + +private class CacheJars : ConfiguredCommand( + "quit", + "Do nothing, only used to prime caches") { + override fun run(bootstrap: Bootstrap?, + namespace: Namespace?, + configuration: ScanInfoShredderConfig?) { + // Doing nothing, as advertised. + } +} diff --git a/scaninfo-shredder/src/test/kotlin/org/ostelco/storage/scaninfo/shredder/MetadataQueryTest.kt b/scaninfo-shredder/src/test/kotlin/org/ostelco/storage/scaninfo/shredder/MetadataQueryTest.kt new file mode 100644 index 000000000..c49baf4f5 --- /dev/null +++ b/scaninfo-shredder/src/test/kotlin/org/ostelco/storage/scaninfo/shredder/MetadataQueryTest.kt @@ -0,0 +1,91 @@ +package org.ostelco.storage.scaninfo.shredder + +import com.google.cloud.datastore.DatastoreException +import com.google.cloud.datastore.Entity +import com.google.cloud.datastore.Query +import com.google.cloud.datastore.StructuredQuery +import kotlinx.coroutines.runBlocking +import org.junit.AfterClass +import org.junit.BeforeClass +import org.mockito.Mockito +import org.ostelco.prime.model.ScanMetadata +import org.ostelco.prime.model.ScanMetadataEnum +import java.io.File +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Class for testing the Datastore queries. + */ +class MetadataQueryTest { + + private fun saveScanMetaData(customerId: String, countryCode: String, id: String, scanReference: String, time: Long): Boolean { + val keyString = "$customerId-${id}" + try { + val key = scanInfoShredder.keyFactory.newKey(keyString) + val entity = Entity.newBuilder(key) + .set(ScanMetadataEnum.ID.s, id) + .set(ScanMetadataEnum.SCAN_REFERENCE.s, scanReference) + .set(ScanMetadataEnum.COUNTRY_CODE.s, countryCode) + .set(ScanMetadataEnum.CUSTOMER_ID.s, customerId) + .set(ScanMetadataEnum.PROCESSED_TIME.s, time) + .build() + scanInfoShredder.datastore.add(entity) + } catch (e: DatastoreException) { + return false + } + return true + } + + @Test + fun testShred() { + var testTime = Instant.now().toEpochMilli() - (scanInfoShredder.expiryDuration) - 10000 + // Add 200 records + for (i in 1..200) { + saveScanMetaData("cid1", "sgp", "id{$i}", "ref${i}", testTime) + if (i == 100) { + testTime = Instant.now().toEpochMilli() + } + } + runBlocking { + val totalItems = scanInfoShredder.shred() + assertEquals(100, totalItems, "Missing some items while scanning for items") + val query = Query.newEntityQueryBuilder() + .setKind(ScanMetadataEnum.KIND.s) + .setLimit(1000) + .build() + val resultList = scanInfoShredder.datastore.run(query) + var count = 0 + while (resultList.hasNext()) { + resultList.next() + count++ + } + assertEquals(100, totalItems, "Non expected count") + } + } + + companion object { + private lateinit var scanInfoShredder:ScanInfoShredder + + @JvmStatic + @BeforeClass + fun init() { + File("encrypt_key_global").delete() + val testEnvVars = Mockito.mock(EnvironmentVars::class.java) + Mockito.`when`(testEnvVars.getVar("JUMIO_API_TOKEN")).thenReturn("") + Mockito.`when`(testEnvVars.getVar("JUMIO_API_SECRET")).thenReturn("") + Mockito.`when`(testEnvVars.getVar("SCANINFO_STORAGE_BUCKET")).thenReturn("") + val config = ScanInfoShredderConfig() + .apply { this.storeType = "inmemory-emulator" } + scanInfoShredder = ScanInfoShredder(config) + scanInfoShredder.init(testEnvVars) + } + + @JvmStatic + @AfterClass + fun cleanup() { + scanInfoShredder.cleanup() + } + } +} diff --git a/scripts/distribute-pantel-secrets.sh b/scripts/distribute-pantel-secrets.sh deleted file mode 100755 index a69d5b0ee..000000000 --- a/scripts/distribute-pantel-secrets.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -# This script finds directories where pantel-prod.json is gitignored in and copies -# the PANTEL_SECRETS_FILE environment variable into these directories. -# These files are needed for the docker compose acceptance tests. - -#### sanity check -if [ -z "${PANTEL_SECRETS_FILE}" ] ; then - echo "ERROR: PANTEL_SECRETS_FILE env var is empty. Aborting!" - exit 1 -fi -#### - -echo; echo "======> Creating pantel-prod.json file, using the env variable PANTEL_SECRETS_FILE" -for LOCATION in $(find . -name .gitignore -exec grep pantel-prod.json '{}' '+' ); do - DIR_NAME=$(dirname $LOCATION) - echo "Creating secrets file: ${DIR_NAME}/pantel-prod.json ..." - echo ${PANTEL_SECRETS_FILE} | base64 -d > ${DIR_NAME}/pantel-prod.json - ls -l ${DIR_NAME}/pantel-prod.json -done -echo '' diff --git a/scripts/distribute-prime-service-account-secrets.sh b/scripts/distribute-prime-service-account-secrets.sh new file mode 100755 index 000000000..5e62a0519 --- /dev/null +++ b/scripts/distribute-prime-service-account-secrets.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# This script finds directories where prime-service-account.json is gitignored in and copies +# the PRIME_SERVICE_ACCOUNT_SECRETS_FILE environment variable into these directories. +# These files are needed for the docker compose acceptance tests. + +#### sanity check +if [ -z "${PRIME_SERVICE_ACCOUNT_SECRETS_FILE}" ] ; then + echo "ERROR: PRIME_SERVICE_ACCOUNT_SECRETS_FILE env var is empty. Aborting!" + exit 1 +fi +#### + +echo; echo "======> Creating prime-service-account.json file, using the env variable PRIME_SERVICE_ACCOUNT_SECRETS_FILE" +for LOCATION in $(find . -name .gitignore -exec grep prime-service-account.json '{}' '+' ); do + DIR_NAME=$(dirname $LOCATION) + echo "Creating secrets file: ${DIR_NAME}/prime-service-account.json ..." + echo ${PRIME_SERVICE_ACCOUNT_SECRETS_FILE} | base64 -d > ${DIR_NAME}/prime-service-account.json + ls -l ${DIR_NAME}/prime-service-account.json +done +echo '' diff --git a/seagull/Dockerfile b/seagull/Dockerfile index 13fc9056e..5958ee175 100644 --- a/seagull/Dockerfile +++ b/seagull/Dockerfile @@ -1,16 +1,20 @@ FROM ubuntu:15.04 -MAINTAINER CSI "csi@telenordigital.com" +LABEL maintainer="dev@redotter.sg" + +RUN sed -i.bak -r 's/(archive|security).ubuntu.com/old-releases.ubuntu.com/g' /etc/apt/sources.list RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential curl git libglib2.0-dev ksh bison flex vim tmux net-tools \ + && apt-get install -y --no-install-recommends build-essential curl git libglib2.0-dev ksh bison flex vim tmux net-tools ca-certificates \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p root/opt/src WORKDIR /root/opt/src -RUN git clone https://github.com/codeghar/Seagull.git ~/opt/src/seagull &&\ - git branch build master &&\ - git checkout build +RUN git clone https://github.com/codeghar/Seagull.git ~/opt/src/seagull +WORKDIR /root/opt/src/seagull +RUN git branch build master +RUN git checkout build + WORKDIR /root/opt/src/seagull/seagull/trunk/src RUN curl --create-dirs -o ~/opt/src/seagull/seagull/trunk/src/external-lib-src/sctplib-1.0.15.tar.gz http://www.sctp.de/download/sctplib-1.0.15.tar.gz &&\ curl --create-dirs -o ~/opt/src/seagull/seagull/trunk/src/external-lib-src/socketapi-2.2.8.tar.gz http://www.sctp.de/download/socketapi-2.2.8.tar.gz @@ -18,11 +22,11 @@ RUN curl --create-dirs -o ~/opt/src/seagull/seagull/trunk/src/external-lib-src/o ksh build-ext-lib.ksh RUN ksh build.ksh -target clean &&\ ksh build.ksh -target all -RUN cp root/opt/src/seagull/seagull/trunk/src/bin/* /usr/local/bin +RUN cp /root/opt/src/seagull/seagull/trunk/src/bin/* /usr/local/bin ENV LD_LIBRARY_PATH /usr/local/bin RUN mkdir -p /opt/seagull &&\ cp -r ~/opt/src/seagull/seagull/trunk/src/exe-env/* /opt/seagull RUN [ "/bin/bash", "-c", "mkdir -p /opt/seagull/{diameter-env,h248-env,http-env,msrp-env,octcap-env,radius-env,sip-env,synchro-env,xcap-env}/logs" ] -WORKDIR /opt/seagull +WORKDIR /config/logs diff --git a/seagull/Dockerfile2 b/seagull/Dockerfile2 new file mode 100644 index 000000000..b2f737b7b --- /dev/null +++ b/seagull/Dockerfile2 @@ -0,0 +1,46 @@ +FROM ubuntu:18.04 as builder + +RUN apt update \ + && apt install -y build-essential curl git libglib2.0-dev ksh bison flex vim + +RUN mkdir -p ~/opt/src \ + && cd ~/opt/src \ + && git clone https://github.com/codeghar/Seagull.git seagull + +RUN mkdir -p ~/opt/src/seagull/seagull/trunk/src/external-lib-src \ + && curl -o ~/opt/src/seagull/seagull/trunk/src/external-lib-src/sctplib-1.0.15.tar.gz http://www.sctp.de/download/sctplib-1.0.15.tar.gz \ + && curl -o ~/opt/src/seagull/seagull/trunk/src/external-lib-src/socketapi-2.2.8.tar.gz http://www.sctp.de/download/socketapi-2.2.8.tar.gz \ + && curl -o ~/opt/src/seagull/seagull/trunk/src/external-lib-src/openssl-1.0.2e.tar.gz https://www.openssl.org/source/openssl-1.0.2e.tar.gz + +RUN cd ~/opt/src/seagull/seagull/trunk/src \ + && ksh build-ext-lib.ksh + +RUN cd ~/opt/src/seagull/seagull/trunk/src \ + && ksh build.ksh -target all + +RUN tar czf /root/bin.tgz ~/opt/src/seagull/seagull/trunk/src/bin/* \ + && tar czf /root/exe-env.tgz ~/opt/src/seagull/seagull/trunk/src/exe-env/* \ + && tar czf /root/pkg.tgz /root/exe-env.tgz /root/bin.tgz + +FROM ubuntu:18.04 as distro +RUN apt update \ + && apt install -y ksh locales \ + && apt upgrade -y \ + && locale-gen en_US.UTF-8 \ + && dpkg-reconfigure --frontend noninteractive locales \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /root/pkg.tgz /root/pkg.tgz +RUN tar xzf /root/pkg.tgz -C /root --strip=1 \ + && tar xzf /root/bin.tgz -C /usr/local/bin --strip=8 \ + && mkdir -p /opt/seagull \ + && tar xzf /root/exe-env.tgz -C /opt/seagull --strip=8 \ + && mkdir -p /opt/seagull/diameter-env/logs \ + && mkdir -p /opt/seagull/h248-env/logs \ + && mkdir -p /opt/seagull/http-env/logs \ + && mkdir -p /opt/seagull/msrp-env/logs \ + && mkdir -p /opt/seagull/octcap-env/logs \ + && mkdir -p /opt/seagull/radius-env/logs \ + && mkdir -p /opt/seagull/sip-env/logs \ + && mkdir -p /opt/seagull/synchro-env/logs \ + && mkdir -p /opt/seagull/xcap-env/logs +RUN rm -f /root/*.tgz \ No newline at end of file diff --git a/seagull/README.md b/seagull/README.md index cd17f621c..df5b48428 100644 --- a/seagull/README.md +++ b/seagull/README.md @@ -4,12 +4,50 @@ A Dockerized version of [Seagull](http://gull.sourceforge.net/ "Seagull") - a mu Based on https://github.com/codeghar/Seagull -The working directory is `/opt/seagull`. +####To test with this Image with docker-compose +* Use the docker-compose file for seagull to start Prime and OCS-gw -Start it with `docker run --rm -it -p --net=host -v ./seagull/:/config -h ocs ` and then use the `diameter-env/run` scripts for testing. +````docker-compose -f docker-compose.seagull.yaml up --build```` -For example: +* Run Docker image -`/config/logs# seagull -conf /config/config/conf.client.xml -dico /config/config/base_cc.xml -scen /config/scenario/ccr-cca.client.multiple-cc-units.init-term.xml -log /config/logs/log.log -llevel A` +``` +docker run --rm -it --network="ostelco-core_net" --ip="172.16.238.2" -v /seagull/:/config -h ocs seagull +``` +Testing can then be done with the command: + +```seagull -conf /config/config/conf.client.xml -dico /config/config/base_cc.xml -scen /config/scenario/ccr-cca.client.multiple-cc-units.init.xml -log /config/logs/log.log -llevel N``` + +Tuning of seagull is done in the configuration file +``` /config/conf.client.xml ``` + + +####To test without docker-compose + +**Start Seagull** + +Check your local IP. + +Update /config/conf.client with your local IP + +``` +docker run --rm -it --net=host -v ./seagull/:/config -h ocs seagull +``` + +**Start OCS-gw** + +Update IPAddress for your LocalPeer in /src/resources/server-jdiameter-config.xml with your local IP + +Start OCS-gw + +**Run test** + +In Seagull: + +``` +cd /config/logs + +seagull -conf /config/config/conf.client.xml -dico /config/config/base_cc.xml -scen /config/scenario/ccr-cca.client.multiple-cc-units.init.xml -log /config/logs/log.log -llevel N +``` diff --git a/seagull/config/conf.client.xml b/seagull/config/conf.client.xml index e49d1854c..1bca57a0d 100644 --- a/seagull/config/conf.client.xml +++ b/seagull/config/conf.client.xml @@ -16,25 +16,22 @@ open-args="mode=client;dest=172.16.238.3:3868"> - + - - - + - - - - - - - - - - - + + + + + + + + + diff --git a/seagull/scenario/ccr-cca.client.multiple-cc-units.init-term.xml b/seagull/scenario/ccr-cca.client.multiple-cc-units.init-term.xml index 66cd1e734..0107a0e18 100644 --- a/seagull/scenario/ccr-cca.client.multiple-cc-units.init-term.xml +++ b/seagull/scenario/ccr-cca.client.multiple-cc-units.init-term.xml @@ -4,6 +4,7 @@ + @@ -12,13 +13,14 @@ - - - - + + + + + @@ -35,22 +37,24 @@ - + + - + + - + @@ -74,7 +78,7 @@ - + @@ -86,6 +90,13 @@ + + @@ -122,7 +133,7 @@ - + @@ -148,21 +159,18 @@ - + - - - + - - + + - @@ -170,6 +178,18 @@ + + + + + + + + + + + + @@ -187,7 +207,7 @@ - + @@ -213,12 +233,10 @@ - - - + - - + + diff --git a/seagull/scenario/ccr-cca.client.total-octets.iec.xml b/seagull/scenario/ccr-cca.client.multiple-cc-units.init.xml similarity index 58% rename from seagull/scenario/ccr-cca.client.total-octets.iec.xml rename to seagull/scenario/ccr-cca.client.multiple-cc-units.init.xml index 942e7ce91..a76254580 100644 --- a/seagull/scenario/ccr-cca.client.total-octets.iec.xml +++ b/seagull/scenario/ccr-cca.client.multiple-cc-units.init.xml @@ -4,21 +4,24 @@ + + - - - - + + + + - + - + + @@ -30,26 +33,28 @@ - + - + + - - - + + + + - + @@ -58,7 +63,7 @@ - + @@ -66,22 +71,29 @@ - + + + + + - + + + - + - - + + + - + @@ -96,8 +108,7 @@ - + - diff --git a/seagull/scenario/ccr-cca.client.total-octets.init-interim_x3-term.xml b/seagull/scenario/ccr-cca.client.total-octets.init-interim_x3-term.xml deleted file mode 100644 index de9e27f19..000000000 --- a/seagull/scenario/ccr-cca.client.total-octets.init-interim_x3-term.xml +++ /dev/null @@ -1,365 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/seagull/scenario/ccr-cca.client.xml b/seagull/scenario/ccr-cca.client.xml deleted file mode 100644 index eddc29317..000000000 --- a/seagull/scenario/ccr-cca.client.xml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/seagull/scenario/ccr-cca.long.client.xml b/seagull/scenario/ccr-cca.long.client.xml deleted file mode 100644 index d0db8c453..000000000 --- a/seagull/scenario/ccr-cca.long.client.xml +++ /dev/null @@ -1,368 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/seagull/scenario/msisdn.csv b/seagull/scenario/msisdn.csv deleted file mode 100644 index 07fbe622b..000000000 --- a/seagull/scenario/msisdn.csv +++ /dev/null @@ -1,11 +0,0 @@ -"string"; -"351910000000"; -"351911111111"; -"351912222222"; -"351913333333"; -"351914444444"; -"351915555555"; -"351916666666"; -"351917777777"; -"351918888888"; -"351919999999"; diff --git a/settings.gradle b/settings.gradle index 701b180b2..96b840e21 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,31 +7,46 @@ include ':analytics-grpc-api' include ':analytics-module' include ':auth-server' include ':bq-metrics-extractor' -include ':client-api' +include ':customer-endpoint' include ':dataflow-pipelines' +include ':data-store' +include ':diameter-ha' include ':diameter-stack' include ':diameter-test' +include ':ekyc' +include ':email-notifier' include ':ext-auth-provider' +include ':ext-myinfo-emulator' include ':firebase-store' include ':firebase-extensions' +include ':graphql' include ':jersey' include ':model' +// include ':myinfo-client' include ':neo4j-admin-tools' include ':neo4j-store' include ':ocs' include ':ocs-grpc-api' +include ':ocs-ktc' include ':ocsgw' include ':ostelco-lib' include ':payment-processor' include ':prime' include ':prime-modules' -include ':prime-client-api' -include ':pseudonym-server' +include ':prime-customer-api' +include ':scaninfo-datastore' +include ':scaninfo-shredder' include ':slack' +include ':sim-administration:es2plus4dropwizard' +include ':sim-administration:jersey-json-schema-validator' +include ':sim-administration:hss-adapter' +include ':sim-administration:ostelco-dropwizard-utils' +include ':sim-administration:simcard-utils' +include ':sim-administration:simmanager' +include ':sim-administration:sm-dp-plus' include ':imei-lookup' - project(':acceptance-tests').projectDir = "$rootDir/acceptance-tests" as File project(':app-notifier').projectDir = "$rootDir/app-notifier" as File project(':admin-api').projectDir = "$rootDir/admin-api" as File @@ -39,25 +54,43 @@ project(':analytics-grpc-api').projectDir = "$rootDir/analytics-grpc-api" as Fil project(':analytics-module').projectDir = "$rootDir/analytics-module" as File project(':auth-server').projectDir = "$rootDir/auth-server" as File project(':bq-metrics-extractor').projectDir = "$rootDir/bq-metrics-extractor" as File -project(':client-api').projectDir = "$rootDir/client-api" as File +project(':customer-endpoint').projectDir = "$rootDir/customer-endpoint" as File project(':dataflow-pipelines').projectDir = "$rootDir/dataflow-pipelines" as File +project(':data-store').projectDir = "$rootDir/data-store" as File +project(':diameter-ha').projectDir = "$rootDir/diameter-ha" as File project(':diameter-stack').projectDir = "$rootDir/diameter-stack" as File project(':diameter-test').projectDir = "$rootDir/diameter-test" as File +project(':ekyc').projectDir = "$rootDir/ekyc" as File +project(':email-notifier').projectDir = "$rootDir/email-notifier" as File project(':ext-auth-provider').projectDir = "$rootDir/ext-auth-provider" as File +project(':ext-myinfo-emulator').projectDir = "$rootDir/ext-myinfo-emulator" as File project(':firebase-store').projectDir = "$rootDir/firebase-store" as File project(':firebase-extensions').projectDir = "$rootDir/firebase-extensions" as File +project(':graphql').projectDir = "$rootDir/graphql" as File project(':jersey').projectDir = "$rootDir/jersey" as File project(':model').projectDir = "$rootDir/model" as File +// project(':myinfo-client').projectDir = "$rootDir/myinfo-client" as File project(':neo4j-admin-tools').projectDir = "$rootDir/tools/neo4j-admin-tools" as File project(':neo4j-store').projectDir = "$rootDir/neo4j-store" as File project(':ocs').projectDir = "$rootDir/ocs" as File project(':ocs-grpc-api').projectDir = "$rootDir/ocs-grpc-api" as File +project(':ocs-ktc').projectDir = "$rootDir/ocs-ktc" as File project(':ocsgw').projectDir = "$rootDir/ocsgw" as File project(':ostelco-lib').projectDir = "$rootDir/ostelco-lib" as File project(':payment-processor').projectDir = "$rootDir/payment-processor" as File project(':prime').projectDir = "$rootDir/prime" as File project(':prime-modules').projectDir = "$rootDir/prime-modules" as File -project(':prime-client-api').projectDir = "$rootDir/prime-client-api" as File -project(':pseudonym-server').projectDir = "$rootDir/pseudonym-server" as File +project(':prime-customer-api').projectDir = "$rootDir/prime-customer-api" as File +project(':scaninfo-datastore').projectDir = "$rootDir/scaninfo-datastore" as File +project(':scaninfo-shredder').projectDir = "$rootDir/scaninfo-shredder" as File project(':slack').projectDir = "$rootDir/slack" as File + +project(':sim-administration:es2plus4dropwizard').projectDir = "$rootDir/sim-administration/es2plus4dropwizard" as File +project(':sim-administration:jersey-json-schema-validator').projectDir = "$rootDir/sim-administration/jersey-json-schema-validator" as File +project(':sim-administration:hss-adapter').projectDir = "$rootDir/sim-administration/hss-adapter" as File +project(':sim-administration:ostelco-dropwizard-utils').projectDir = "$rootDir/sim-administration/ostelco-dropwizard-utils" as File +project(':sim-administration:simcard-utils').projectDir = "$rootDir/sim-administration/simcard-utils" as File +project(':sim-administration:simmanager').projectDir = "$rootDir/sim-administration/simmanager" as File +project(':sim-administration:sm-dp-plus').projectDir = "$rootDir/sim-administration/sm-dp-plus" as File + project(':imei-lookup').projectDir = "$rootDir/imei-lookup" as File diff --git a/sim-administration/.all-of-es2plus.json.swp b/sim-administration/.all-of-es2plus.json.swp new file mode 100644 index 000000000..b98a1d067 Binary files /dev/null and b/sim-administration/.all-of-es2plus.json.swp differ diff --git a/sim-administration/.gitignore b/sim-administration/.gitignore new file mode 100644 index 000000000..525977926 --- /dev/null +++ b/sim-administration/.gitignore @@ -0,0 +1,2 @@ +dependency-reduced-pom.xml +sim_inventory.db diff --git a/sim-administration/ES2+.md b/sim-administration/ES2+.md new file mode 100644 index 000000000..2c9ceda93 --- /dev/null +++ b/sim-administration/ES2+.md @@ -0,0 +1,49 @@ +# Exploratory code for understanding the eSIM protocols + +Based on information from +https://www.gsma.com/newsroom/wp-content/uploads/SGP.22-v2.0.pdf + +... Can the JSON files be interpreted as swagger spec, and be +converted into a server/client implementation? If so can we use it to +flesh out the interaction between the BSS and the SM-DP+? + +Use json schema files (stored in the resources directory of the +source code) to verify that the input / output is conformant to the +GSMA spec. Use jackson's ordinary methods for handling incoming +requests and replies. + + +TODO +--- + +* Verify that the protocol is actually sane (don't have _any_ examples + of ES2+ interactions that can be used to check assumptions made + during development. + +* Introduce exception interceptors that catches exceptions + generated by the web resource, but encapsulates them in the + ES2+-conformant JSON that the resource is supposed to produce. + +* Use the generated openapi spec to generate clients. + +* Test two-way SSL authentication. + +* Make test-runs of some typical client/SM-DP+ interactions, + possibly logging them using plant uml autogenerated + diagrams. + +* Connect to proper SM-DP+, watch everything explode + ... observe fix repeat until it's working. + +* Integrate into a proper workflow for subscriber signup, + possibly involving generation of QR-codes :-) + + +TODO +--- + +1. Make all the rudimentary tests work, _without_ JSON schemas to match +1. Optimize data structures to make it nicer. E.g. by aoviding multiple empty classes +1. and also develop the json schemas for both requests and responses +1. Make some recactoring in the data structure. As it is today it is simply a mess. +1. See if it's possible to find an example of a valid SM-DP2 interaction somewhere diff --git a/sim-administration/README.md b/sim-administration/README.md new file mode 100644 index 000000000..74496f09d --- /dev/null +++ b/sim-administration/README.md @@ -0,0 +1,193 @@ +# Exploratory code for understanding the eSIM protocols + +Based on information from +https://www.gsma.com/newsroom/wp-content/uploads/SGP.22-v2.0.pdf + +... Can the JSON files be interpreted as swagger spec, and be +converted into a server/client implementation? If so can we use it to +flesh out the interaction between the BSS and the SM-DP+? + +Use json schema files (stored in the resources directory of the +source code) to verify that the input / output is conformant to the +GSMA spec. Use jackson's ordinary methods for handling incoming +requests and replies. + +TODO (Prioritized) +--- + + +* Make class comments for every high level class. + +* Make an acceptance test that lets the service run in conjunction with + an ESP where the ESP terminates the SSL, using a client certificate, + as we are doing today using the dropwizard-terminated SSL. The test will + have to send sufficient information in headers to the service so that it + can still identify the incoming user. It seems that this will have to be a + two-step process: + + 1. Get the ESP to run locally in some trivial fashion, forwarding requests + to the sim management service: + + https://cloud.google.com/endpoints/docs/openapi/running-esp-localdev + + 2. Then modify the ESP to accept client certificate authenticated + connections, and modify headers of forwarded requests to identify which + certificate has been used: + + https://fardog.io/blog/2017/12/30/client-side-certificate-authentication-with-nginx/ + +* Make junit-tests that runs the dropwizard application in a mode to permit it + to accept TSL connections that are signed by a self-signed certificate, + using a certificate chain where the signing authority is included. The tests + should as far as possible use standard configurations of both + dropwizard and the jersey client to make this happen. A script describing + how to generate new signed certificates should also be included. + + Look here for description on how to make this happen + https://stackoverflow.com/questions/34908947/dropwizard-client-certificate-authentication-via-httpclient-key-trust-store + + Question: Can we run kotlin scripts as docker containers, and if we can, would + that make it simpler to make the little things we need to serve as an execution + environment during testing? + + +TODO (not prioritized) +--- + +* Think _very_hard_ about how to make an acceptance test for this thing. It should + * Have a minimum of different executables. + * Preferably run in Docker Compose, but kubernetes is also an option. + * Generate a clear-cut test result, preferably in junit, preferable with coverage. + * Should be simple to run from the command line, no magic necessary. + +* General refactoring, weed out TODOs, make constants, set protocol versions + correctly etc. + +* Authentication: + * Design certificate structure (SM-DP+, CSR created by whom, signed by whom + etc.) Figure out how and where these certs should be stored. + * Same thing for HSS. + * Make test certificates that are self-signed and are included in the + acceptance tests. + * Document the whole procedure using markdown and plant uml. + +* Make a docker compose testable ensemble of executables for acceptance test. + + * Must contain: + + * A sim inventory service, the test article, with a config file. + * SIM admin database, running in postgres, and sufficient script support to + start it up with the appropriate database schema installed. + * SM-DP+ server. Very simplified, serving + a highly restricted set of usecases. Must be capable of reporting + back to client about what happens. Must therefore use a valid + certificate structure. + * An HSS emulator, that the sim inventory can talk to to enable + sim profiles. + * An .out file that can be injected into the Sim admin database + as the first step of the acceptance test. + * A script emulating the behavior of Prime talking to the + the SIM inventory. Not necessarily Prime, but should use + the same libraries that we intend to be used from Prime. + * Some mechanism that will deliver the results of running the acceptance + tests to a surrounding environment that can determine if the test + ran to completion or not. + + * Nice to have: + + * Prometheus integration + +* Return channel for ES2+ + + * Figure out where we want to place the return channel for ES2+ + * Set up the certificates, CSRs etc. + * Set up a first version that does nothing but receiving the information + and write it to a log (that is then picked up by the standard + logging interfaces). This should be a version of the sim inventory server, + but it should run in a mode that is made just for this usecase. + * Later (possibly much later), write code that mutates the common + data model, or publishes updates to an internal bus of some sort. + + +* Make code that generates QR Code as picture (png?) that can be embedded + in an user interface. + +* Code walkthrough of usecases, all the way down into database structures. + * Must be done asap by the dev team. + +* Documentation + * The documentation should by and large be written in markdown and + plant UML. + * A domain model for the SIM cards and their life cycle. + * API documentation with all parameters throroughly described, and + examples of use. + +* Instrumentation + * Expose metrics to Prometheus via standard techniques + +* Integration to live test-SM-DP+ + * Complete an SM-DP+ communications class that contains both health + check and metrics for communications overhead. Use standard + dropwizard mechanisms for this. + + * Figure out how to do heartbeat. Must ask SM-DP+ vendor about this. Must be + done over ES2+ protocol. + + * First goal to aim for is to provision a test profile to a live commercial + handset. + +* Integration to live HSS + * Authentication + * Heartvbeat + * Instrumentation + * Synchronization with datamodel (misc. states for SIM). + + +* Sync design of internal API with requirements from user interface flow + => ... then ruthlessly prune elements that are not required for + UX or testing. + + +Ideas +--- + +* Think this through: Would it make sense to have emulators for + the HSS and SM-DP+es we will integrate to, just to see that the + system is capable of connecting to them using the appropriate + set of certificates etc.? + +* Follow through on https://rohannagar.github.io/2018-04-11/dropwizard-on-kubernetes + +* docker build -t ostelco/simadmin . + ... to build a new version of the simadmin thingy that is + also reachable by kubernetes locally. + +* Re-enable json schema validation on everything, fixing the broken + schemas in the process. + +* Make some refactoring in the data structures. + +* Use the generated openapi spec to generate clients. + +* Add and test two-way SSL authentication. + +* Make test-runs of some typical client/SM-DP+ interactions, + possibly logging them using plant uml autogenerated + diagrams. + +* Connect to proper SM-DP+, watch everything explode + ... observe fix repeat until it's working. + +* Integrate into a proper workflow for subscriber signup, + possibly involving generation of QR-codes :-) + +* Figure out how to integrate with prime, possibly making the whole thing + a part of prime. + +* Figure out how to expose endpoints using google cloud endpoints, and + preserving authentication for incoming ES2+ callbacks. + + +Secret password for keystore used in testing is "secret" + + diff --git a/sim-administration/certificate-authority-simulated/.gitignore b/sim-administration/certificate-authority-simulated/.gitignore new file mode 100644 index 000000000..d6f6dc3b9 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/.gitignore @@ -0,0 +1,2 @@ +requester +cert-auth diff --git a/sim-administration/certificate-authority-simulated/circle-simulated.sh b/sim-administration/certificate-authority-simulated/circle-simulated.sh new file mode 100755 index 000000000..361d5ee71 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/circle-simulated.sh @@ -0,0 +1,431 @@ +#!/bin/bash + + + +### +### Run a full CSR cycle against a CA. Do it all from scatch, generating +### root certificate for the ca, generating the csr, signing the csr +### an injecting the certs in to java keyrings. +### + + +## To reset the crypto artefacts repository, start script by setting RESET_CRYPTO_ARTEFACTS variable +## to a non-null value, e.g. "RESET_CRYPTO_ARTEFACTS=yes ./circle-simulated.sh" + + +## +## Key length. Should be 2048, but may be smaller during testing. +## +KEY_LENGTH=2048 + + +## +## Check for dependencies +## +DEPENDENCIES="keytool openssl" + +for tool in $DEPENDENCIES ; do + if [[ -z "$(which $tool)" ]] ; then + (>&2 echo "$0: Error. Could not find dependency $tool") + exit 1 + fi +done + + +## +## Setting up directories for the various +## roles, deleting old files. Each of the actors +## (smdp+ and sim manager) are represented by +## a directory. That directory is then populated with +## misc keys, certificats and csrs. +## + + +ARTEFACT_ROOT=crypto-artefacts + + +# Reset if requested +if [[ -n "$RESET_CRYPTO_ARTEFACTS" ]] ; then + if [[ -f "$ARTEFACT_ROOT" ]] ; then + rm -rf $ARTEFACT_ROOT + fi +fi + +SIM_MANAGER="sim-mgr" +SMDPPLUS="sm-dp-plus" + +ACTORS="$SIM_MANAGER $SMDPPLUS" + +for x in $ACTORS ; do + if [[ ! -d "$ARTEFACT_ROOT/$x" ]] ; then + mkdir -p "$ARTEFACT_ROOT/$x" + fi +done + + +## +## Generate filenames from actorname and role +## + +function generate_filename { + local actor=$1 + local role=$2 + local suffix=$3 + + if [[ -z "$actor" ]] ; then + (>&2 echo "No actor given to generate_filename") + exit 1 + fi + if [[ -z "$role" ]] ; then + (>&2 echo "No role given to generate_filename") + exit 1 + fi + + if [[ -z "$suffix" ]] ; then + (>&2 echo "No suffix given to generate_filename") + exit 1 + fi + + echo "${ARTEFACT_ROOT}/${actor}/${role}.${suffix}" +} + + + +function keystore_filename { + local actor=$1 + local role=$2 + local keystore_type=$3 + echo $(generate_filename $actor "${role}_${keystore_type}" "jks" ) +} + +function csr_filename { + local actor=$1 + local role=$2 + echo $(generate_filename $actor $role "csr" ) +} + + +function crt_filename { + local actor=$1 + local role=$2 + echo $(generate_filename $actor $role "crt" ) +} + + +function combined_crt_filename { + local actor=$1 + local role=$2 + echo $(generate_filename $actor "combined_$role" "crt" ) +} + + +function key_filename { + local actor=$1 + local role=$2 + echo $(generate_filename $actor $role "key" ) +} + + +function crt_config_filename { + local actor=$1 + local role=$2 + echo $(generate_filename $actor $role "csr_config" ) +} + +## +## Generating Certificate Signing Requests (CSRs) and signing them. +## + + +function generate_cert_config { + local cert_config=$1 + local key +file=$2 + local distinguished_name=$3 + local country=$4 + local state=$5 + local location=$6 + local organization=$7 + local common_name=$8 + + + echo "local cert_config=$1" + echo "local keyfile=$2" + echo "local distinguished_name=$3" + echo "local country=$4" + echo "local state=$5" + echo "local location=$6" + echo "local organization=$7" + echo "local common_name=$8" + + + cat > $cert_config <&2 echo "$0: Error. Could not find csr $csr_file") + exit 1 + fi + + if [[ ! -f "$ca_crt" ]] ; then + (>&2 echo "$0: Error. Could not find CA crt $csr_file") + exit 1 + fi + + if [[ ! -f "$crt_file" ]] ; then + openssl x509 -req -in $csr_file -CA $ca_crt -CAkey $ca_key -CAcreateserial -out $crt_file + else + echo "Signed certificate already exists in file $crt_file, not creating again" + fi + if [[ ! -f "$crt_file" ]] ; then + echo "Could not create signed certificate file $crt_file" + fi +} + + +echo "Sign server certificates using own CA" +sign_csr "$SIM_MANAGER" "sk" "$SIM_MANAGER" "ca" +sign_csr $SMDPPLUS "sk" $SMDPPLUS "ca" + + +echo "Countersign client certificates" +sign_csr "$SIM_MANAGER" "ck" "$SIM_MANAGER" "ca" +sign_csr $SMDPPLUS "ck" $SMDPPLUS "ca" + + + +## +## Generate keytool files based on the keys stored +## in the crypto storage. +## + +# +# Generate and/or populate a keystore file with +# the certificates given as fourth argument and onwards. +# First argument is the actor managing the keystore, the +# second is the role for which it is used (e.g. "client keys") +# third argument is either "trust" or "keys" depending on if this +# keystore is used in a "trustkeys" or "secretkeys" role. +# +# Usage: +# +# populate_keystore "sim-manager" "ck" "trust" foobar.crt bartz.crt .. +# +# +function populate_keystore { + local actor=$1 ; shift + local role=$1 ; shift + local keystore_type=$1; shift + local certs="$*" + + local keystore_filename=$(keystore_filename $actor $role $keystore_type) + local common_password="superSecreet" + + +# keytool -import -trustcacerts -alias mydomain -file mydomain.crt -keystore keystore.jks + + echo "Creating keystore $keystore_filename" + for cert in $certs ; do + echo " Importing cert $cert" + keytool \ + -noprompt -storepass "${common_password}" \ + -importcert -trustcacerts -alias "$(basename $(dirname $cert))_$(basename $cert)" \ + -file $cert -keystore $keystore_filename + done +} + + + +# +# Generate all the eight kinds of combinations that can be made. +# One trust/keys pair for each of the four {sim_manager, smdpplus} x {ck, sk} +# combinations. +# + + + +# populate_keystore $SIM_MANAGER "ck" "trust" $(crt_filename $SMDPPLUS "ca") +# populate_keystore $SIM_MANAGER "ck" "keys" $(crt_filename $SIM_MANAGER "sk") +# populate_keystore $SIM_MANAGER "sk" "trust" $(crt_filename $SMDPPLUS "ca") +# populate_keystore $SIM_MANAGER "sk" "keys" $(crt_filename $SIM_MANAGER "sk") + + +# populate_keystore $SMDPPLUS "ck" "trust" $(crt_filename $SIM_MANAGER "ca") +# populate_keystore $SMDPPLUS "ck" "keys" $(crt_filename $SMDPPLUS "sk") + + +# Maybe this is the clue I need? +# https://docs.oracle.com/cd/E19509-01/820-3503/ggfhb/index.html +# https://stackoverflow.com/questions/19552380/no-certificate-matches-private-key-while-generating-p12-file +# https://coderwall.com/p/3t4xka/import-private-key-and-certificate-into-java-keystore +# https://www.ibm.com/support/knowledgecenter/en/SSWHYP_4.0.0/com.ibm.apimgmt.cmc.doc/task_apionprem_generate_pkcs_certificate.html +# https://www.pixelstech.net/article/1450354633-Using-keytool-to-create-certificate-chain + +# https://stackoverflow.com/questions/41293778/jetty-javax-net-ssl-sslhandshakeexception-no-cipher-suites-in-common +# - claims that both key and cert must have same alias in keystore + +# openssl pkcs12 -export -in $(crt_filename $SMDPPLUS "sk") -inkey $(key_filename $SMDPPLUS "sk") -chain -CAfile $(crt_filename $SMDPPLUS "ca") -name "not-really-ostelco.org" -out mykeystore.pkcs12 + +# cat $(crt_filename $SMDPPLUS "sk") $(crt_filename $SMDPPLUS "ca") > caChain.pem +# openssl pkcs12 -inkey $(key_filename $SMDPPLUS "sk") -in $(crt_filename $SMDPPLUS "sk") -export -out mykeystore.pkcs12 -CAfile caChain.pem -chain + +# keytool -noprompt -storepass superSecreet -v -importkeystore -srckeystore mykeystore.pkcs12 -srcstoretype PKCS12 -destkeystore $(keystore_filename $SMDPPLUS "sk" "keys" ) -deststoretype JKS + +populate_keystore $SMDPPLUS "sk" "trust" $(crt_filename $SIM_MANAGER "ca") $(crt_filename $SMDPPLUS "ca") +# populate_keystore $SMDPPLUS "sk" "keys" $(crt_filename $SMDPPLUS "ca") $(crt_filename $SMDPPLUS "sk") +# populate_keystore $SMDPPLUS "sk" "keys" mykeystore.pkcs12 + + + +# keytool -genkey -keyalg RSA -alias selfsigned -keystore keystore.jks -storepass superSecreet -validity 360 -keysize 2048 + +# openssl req -new -x509 -newkey rsa:2048 -sha256 -key jetty.key -out jetty.crt +# openssl pkcs12 -inkey jetty.key -in jetty.crt -export -out idp-browser.p12 +openssl pkcs12 -inkey $(key_filename $SMDPPLUS "sk") -in $(crt_filename $SMDPPLUS "sk") -export -out mykeystore2.pkcs12 \ No newline at end of file diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.crt b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.crt new file mode 100644 index 000000000..7deac831a --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUjCCAjoCCQCMoHiOZ+er5TANBgkqhkiG9w0BAQsFADBrMQswCQYDVQQGEwJO +TzENMAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEbMBkGA1UECgwSTm90IHJl +YWxseSBvc3RlbGNvMSEwHwYDVQQDDBgqLm5vdC1yZWFsbHktb3N0ZWxjby5vcmcw +HhcNMTgxMjIwMjE1MDMxWhcNMTkwMTE5MjE1MDMxWjBrMQswCQYDVQQGEwJOTzEN +MAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEbMBkGA1UECgwSTm90IHJlYWxs +eSBvc3RlbGNvMSEwHwYDVQQDDBgqLm5vdC1yZWFsbHktb3N0ZWxjby5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIsnLfQ6RNasRsG+J2aoWcWSS8 +/YTxjvwsrZtz95CzagMUN5/zyGOOYq9YkP8+Mkyvo4r/j9GRyrnuo4mVmTe6tZz0 +5mgB/cxljb5jK45HGMaWYGAv/uosbwNVOsKZXxx3rit8s1S82jviNDM1Hg/u/n7B +2aFRVQxkFHDxRCReyMlD2buXByz/WYkwhxHfWkIP03Z71RPlyF2PxSIVQPdu3db0 +4LuUVGI06qLCITC3uuz+zYAWaypZGSf00d37agJOIwQQnEtMCUKoK61FCVwQIEHA +33YrEodc3Lh7fymQKa+hVLg6Oe/ouCrvqw+QPoWgK0Pi5xxNvGzeAEC5xb/ZAgMB +AAEwDQYJKoZIhvcNAQELBQADggEBADw3lzgsPbE2Pi1kpx2hxyxwEv+xckJPKg1U +CyY/FeE9KaXudx9v7JHKBbz2LfaBF+pA2pXUAFgFqTCZnTi4yXfmRONnopnTGBeH +FpzPHrg3Za6mm0bSTa46cKO6Z7Y+3ymUfeSbrQB9lDfr9oaRaGLRSHvXuBW7MOf2 +LV/ogWJyIfK7/TQD6n1cUXnC8oi5mIGwMGCWli0iF8xbEgY5QiKq+NBe8AJOxXY6 +mu8mYu6CqJIeU1LWHqKqM55x2qt6FnOyYSVZVSKI5DE+ShEZ7ysMthB+U8q5qB2n +CemH7mUNMkmOAPEZk4dMLHmYKJ8Ey1BLAVIVOb4pL27O23R0yqg= +-----END CERTIFICATE----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.csr_config b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.csr_config new file mode 100644 index 000000000..fe62839f9 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.csr_config @@ -0,0 +1,60 @@ +# The main section is named req because the command we are using is req +# (openssl req ...) +[ req ] +# This specifies the default key size in bits. If not specified then 512 is +# used. It is used if the -new option is used. It can be overridden by using +# the -newkey option. +default_bits = 2048 + +# This is the default filename to write a private key to. If not specified the +# key is written to standard output. This can be overridden by the -keyout +# option. +default_keyfile = crypto-artefacts/sim-mgr/ca.key + +# If this is set to no then if a private key is generated it is not encrypted. +# This is equivalent to the -nodes command line option. For compatibility +# encrypt_rsa_key is an equivalent option. +encrypt_key = no + +# This option specifies the digest algorithm to use. Possible values include +# md5 sha1 mdc2. If not present then MD5 is used. This option can be overridden +# on the command line. +default_md = sha1 + +# if set to the value no this disables prompting of certificate fields and just +# takes values from the config file directly. It also changes the expected +# format of the distinguished_name and attributes sections. +prompt = no + +# if set to the value yes then field values to be interpreted as UTF8 strings, +# by default they are interpreted as ASCII. This means that the field values, +# whether prompted from a terminal or obtained from a configuration file, must +# be valid UTF8 strings. +utf8 = yes + +# This specifies the section containing the distinguished name fields to +# prompt for when generating a certificate or certificate request. +distinguished_name = not-really-ostelco.org + +# this specifies the configuration file section containing a list of extensions +# to add to the certificate request. It can be overridden by the -reqexts +# command line switch. See the x509v3_config(5) manual page for details of the +# extension section format. +req_extensions = my_extensions + +[ not-really-ostelco.org ] +C = NO +ST = Oslo +L = Oslo +O = Not really ostelco +CN = *.not-really-ostelco.org + +[ my_extensions ] +basicConstraints=CA:FALSE +subjectAltName=@my_subject_alt_names +subjectKeyIdentifier = hash + +[ my_subject_alt_names ] +DNS.1 = *.not-really-ostelco.org +# Multiple domains could be listed here, but we're not doing +# doing that now. diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.key b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.key new file mode 100644 index 000000000..8c802f8f6 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDIsnLfQ6RNasRs +G+J2aoWcWSS8/YTxjvwsrZtz95CzagMUN5/zyGOOYq9YkP8+Mkyvo4r/j9GRyrnu +o4mVmTe6tZz05mgB/cxljb5jK45HGMaWYGAv/uosbwNVOsKZXxx3rit8s1S82jvi +NDM1Hg/u/n7B2aFRVQxkFHDxRCReyMlD2buXByz/WYkwhxHfWkIP03Z71RPlyF2P +xSIVQPdu3db04LuUVGI06qLCITC3uuz+zYAWaypZGSf00d37agJOIwQQnEtMCUKo +K61FCVwQIEHA33YrEodc3Lh7fymQKa+hVLg6Oe/ouCrvqw+QPoWgK0Pi5xxNvGze +AEC5xb/ZAgMBAAECggEAG794XF/4xm9diujsDZ06rdwxVSPkDpXLTc4O8SCoU/Xz +SQdLySPKh/Qi9CbP82R27823uQ/EVNjnjhP0QMe3Scw0UDPO63+Qk/Xd/c4W1MOb +KU1X3yrqa4xJtK30G8pnoDBneM0+iQHPR74Z2l02rL9o/Ro+0ITmuquM1f6q3KO9 +X534FXN3trLiIUvOG5o3V9s3TwoQ8mOpRcL0y9GHoCxQ/yVGIgapcbrCbVuUcqpK +UkXi7NB3/grwcBbImwC1PrIF9x9GSgtfnRX28QI/bG5tO9EtJGFHx1fhBtYJx+NH +zcC7MhTO6yW5rE8Q7FBQnQ6xVL/ur2Yp11tDcRsngQKBgQDrGSrCPWLVWEe7Ze7i +fiHCiUBWT1MgvumIKkiThJeIyh/Lgn1VDswozAa6vDoIZRosmeLLRUklT+A8K5fu +AW+QQo0dhUiLOtaqRAterQNcOh7wnF7RtE5kaGv4ypmSH8+oD2AdLA+RAab21W5l +pb3mro/fSbzAA9udvxVBY8jVaQKBgQDaik9f3kYkdBzTzd+nVI3hTp45c/ocKYmM +JyA14qvDv/ObhhnoHANdlzgm96KQmPT5VhQBrGSWzor7dzOlSB37D4iL55gyDw9Y +YpzJ6zHdLbwbUFbp+o3i6oFLaO9LC4PhgzE86/q1PrCOJMheNzPU1wMns2Bz3tT+ +FzruQrsY8QKBgQCTmCE4AMHjnqCqDbyDhRw8vV4e0X4muLR0P9eAhWV9Aygi47E7 +jSavFifDZBgq3Q4pohK3+q+JNTRZkiS3zz7zGlTti5eXkUDjdASPU58gb3ytIf6F +OOVeNBBVCRyQmFgN8lks91RufNMNc8DzH7Kw+DZqwCg3hDSEPEpj2vliwQKBgQCZ +uN5abgxiyfYZGmBu4HAogteTTDwrISCqoD7tCHOP7u6ZgDuq5EGNzLfn2RrVoXH4 +DQ9nme7rkX97oP6IDXFhTyzaVF7fH27I2hy1f6YEkY4WnG12ihLSAehOthJUdFYh +A9pXoxon8V/ZrI/wjd38of2LKIb5Gk4yKP7/55qZ0QKBgFOQaYU6AoGX8XfDXr0m +/s2Mj6WcKoJ2No7pbDAlwJ+igEzYVsPnVtp9RYwKsKOyDi9VDwGp5943ErB2BCYV +Kf7D2bPLNVh+cU470DxIuCtQIwdAf/FxSfBsr1KY2PQuQkHvV7u8iIDXJNnFOpVO +Qveve2ovWuV9jUyf/O8ANmlh +-----END PRIVATE KEY----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.srl b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.srl new file mode 100644 index 000000000..db2bf5d67 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ca.srl @@ -0,0 +1 @@ +96B0B66BA6415C15 diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.crt b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.crt new file mode 100644 index 000000000..dbc96ef13 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUjCCAjoCCQCWsLZrpkFcFTANBgkqhkiG9w0BAQUFADBrMQswCQYDVQQGEwJO +TzENMAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEbMBkGA1UECgwSTm90IHJl +YWxseSBvc3RlbGNvMSEwHwYDVQQDDBgqLm5vdC1yZWFsbHktb3N0ZWxjby5vcmcw +HhcNMTgxMjIwMjE1MDM1WhcNMTkwMTE5MjE1MDM1WjBrMQswCQYDVQQGEwJOTzEN +MAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEbMBkGA1UECgwSTm90IHJlYWxs +eSBvc3RlbGNvMSEwHwYDVQQDDBgqLm5vdC1yZWFsbHktb3N0ZWxjby5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCd4Sx0nw2s+3QdLAuH0P0reb5K +M9p9iIRc8uJ4PMx9HeBOy6pZqTuh2SnjLFGqJWMhn1Em0s8aYmqdPhglwbpEObPD +35szaZN8MFW9Q34WDABJhD3lw8tdXXXzXCNEFQ+du5qx1RH/3e9lMgiGVoWxRh1N +FBROi3m5a0VG1laaKx+r5hKWBvpleHXr3wxST5JS9fSYXVmAnyZPk1BoOLIfVRST +u8yHowjCbosf99ij0031iNyk9gHKOwMPFtcD4cBx1SSNkvbdsQPF4QT6cHj2kSzY +D0ytvJgVTDA40mybpGYYTk54Brng2Y8jIAkuTDDrhwJuEc3uvkZ00uLnYph1AgMB +AAEwDQYJKoZIhvcNAQEFBQADggEBAHr9PuDllazkwx/d6S3vNAu7btoKcauVQoup +s0meAe0fLAlijJUT0k7uUr6hqLokH34fpIuVANfyYcPnq4/cMwvc6mZDjBNfXLLm +N5zMJuWDhE3EVRR6PC+e5DdD0GbA83FWxvTh9SpOxqK1/ak3uOlejfnK4VgPRkx9 +TuF4GXH0lpZssbotGluUKF5DbypzB9M0gpPT91/6+6rInUCzvnwPGZhT6u54WX9w +I2sgzKtIIGc06YmjkrzCE/DHNdh4hAimajzHZ+t249yLwziuEYhkXwIBXuwoimE+ +E7xuC6PgyKhLQ/6WfxPDtPY+wC7ucrtRcqQwTBs8FKv0/e3twBg= +-----END CERTIFICATE----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.csr b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.csr new file mode 100644 index 000000000..93e5eb226 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.csr @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIDEDCCAfgCAQAwazELMAkGA1UEBhMCTk8xDTALBgNVBAgMBE9zbG8xDTALBgNV +BAcMBE9zbG8xGzAZBgNVBAoMEk5vdCByZWFsbHkgb3N0ZWxjbzEhMB8GA1UEAwwY +Ki5ub3QtcmVhbGx5LW9zdGVsY28ub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAneEsdJ8NrPt0HSwLh9D9K3m+SjPafYiEXPLieDzMfR3gTsuqWak7 +odkp4yxRqiVjIZ9RJtLPGmJqnT4YJcG6RDmzw9+bM2mTfDBVvUN+FgwASYQ95cPL +XV1181wjRBUPnbuasdUR/93vZTIIhlaFsUYdTRQUTot5uWtFRtZWmisfq+YSlgb6 +ZXh1698MUk+SUvX0mF1ZgJ8mT5NQaDiyH1UUk7vMh6MIwm6LH/fYo9NN9YjcpPYB +yjsDDxbXA+HAcdUkjZL23bEDxeEE+nB49pEs2A9MrbyYFUwwONJsm6RmGE5OeAa5 +4NmPIyAJLkww64cCbhHN7r5GdNLi52KYdQIDAQABoGAwXgYJKoZIhvcNAQkOMVEw +TzAJBgNVHRMEAjAAMCMGA1UdEQQcMBqCGCoubm90LXJlYWxseS1vc3RlbGNvLm9y +ZzAdBgNVHQ4EFgQUfLjLOetipRXiTszdXMMsGikfmcMwDQYJKoZIhvcNAQEFBQAD +ggEBAJnyhoWgqn5H4LGNZazLZtrsGHrWxbNOVvSjHc1oATUQDc1sF0QhoCMMc6gX +6rlJQM4v5ReZihYcCz7UVzqdlLkGpt7yzskmJF+w4SQ/219YbbtgcR1ZRG9bEnAH +xnmKMAioFdtl7Vmr39V8u8BLvWPJxNsTp0cFw5fnd5ohpLRcrS5Cc3zpjjenv9+s +9JcBsRed6mNFPRb8uIOplJImnJ4Tt6SGxk1lLkVYZGKswCbqx8EO2UycxyFWr08h +SVaEmpep8lQtGkpmS4lqt89hMTRYFmpCGTlVhKp9LWacFewqZt+hlAE47S+L6Sq1 +4so+nfVxP0SJyIg65tP2qIeP7KU= +-----END CERTIFICATE REQUEST----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.csr_config b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.csr_config new file mode 100644 index 000000000..b0bdff1e2 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.csr_config @@ -0,0 +1,60 @@ +# The main section is named req because the command we are using is req +# (openssl req ...) +[ req ] +# This specifies the default key size in bits. If not specified then 512 is +# used. It is used if the -new option is used. It can be overridden by using +# the -newkey option. +default_bits = 2048 + +# This is the default filename to write a private key to. If not specified the +# key is written to standard output. This can be overridden by the -keyout +# option. +default_keyfile = crypto-artefacts/sim-mgr/ck.key + +# If this is set to no then if a private key is generated it is not encrypted. +# This is equivalent to the -nodes command line option. For compatibility +# encrypt_rsa_key is an equivalent option. +encrypt_key = no + +# This option specifies the digest algorithm to use. Possible values include +# md5 sha1 mdc2. If not present then MD5 is used. This option can be overridden +# on the command line. +default_md = sha1 + +# if set to the value no this disables prompting of certificate fields and just +# takes values from the config file directly. It also changes the expected +# format of the distinguished_name and attributes sections. +prompt = no + +# if set to the value yes then field values to be interpreted as UTF8 strings, +# by default they are interpreted as ASCII. This means that the field values, +# whether prompted from a terminal or obtained from a configuration file, must +# be valid UTF8 strings. +utf8 = yes + +# This specifies the section containing the distinguished name fields to +# prompt for when generating a certificate or certificate request. +distinguished_name = not-really-ostelco.org + +# this specifies the configuration file section containing a list of extensions +# to add to the certificate request. It can be overridden by the -reqexts +# command line switch. See the x509v3_config(5) manual page for details of the +# extension section format. +req_extensions = my_extensions + +[ not-really-ostelco.org ] +C = NO +ST = Oslo +L = Oslo +O = Not really ostelco +CN = *.not-really-ostelco.org + +[ my_extensions ] +basicConstraints=CA:FALSE +subjectAltName=@my_subject_alt_names +subjectKeyIdentifier = hash + +[ my_subject_alt_names ] +DNS.1 = *.not-really-ostelco.org +# Multiple domains could be listed here, but we're not doing +# doing that now. diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.key b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.key new file mode 100644 index 000000000..15cee9e97 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/ck.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCd4Sx0nw2s+3Qd +LAuH0P0reb5KM9p9iIRc8uJ4PMx9HeBOy6pZqTuh2SnjLFGqJWMhn1Em0s8aYmqd +PhglwbpEObPD35szaZN8MFW9Q34WDABJhD3lw8tdXXXzXCNEFQ+du5qx1RH/3e9l +MgiGVoWxRh1NFBROi3m5a0VG1laaKx+r5hKWBvpleHXr3wxST5JS9fSYXVmAnyZP +k1BoOLIfVRSTu8yHowjCbosf99ij0031iNyk9gHKOwMPFtcD4cBx1SSNkvbdsQPF +4QT6cHj2kSzYD0ytvJgVTDA40mybpGYYTk54Brng2Y8jIAkuTDDrhwJuEc3uvkZ0 +0uLnYph1AgMBAAECggEARof38km0NFlJsFai+BP3ZlrHFiNAMuCwMU4Yupp+yE52 +jP0Tp2ahS1bXDbQY76gwNy8TdAKtnx3kf0bkRsnbSut0UcctcLRzvQFi7GCgjXzj +C/TWKjVkPtun/AZngtzd0xuiqluD5QjjEBRgFpPEukOWh36267gHszwnANR0b/6s +QziNul0bSvsxUuQ0WkR83cHl/b+enlL+WBd9QfiwBwxKmdg9JGc1MSIv2TaHxr6J +EZbGeX6DxcAU1OGFnaq8cUJgMNGPXOy8APWyyQHzKl+aq7o2zndKAJAXIp8w6WK/ +qVt8vlJmKcHVEpXWTg/2f7JWWkRK/Y/qPKBrm68GaQKBgQDN91CbkpFuXD147NCh +yN0S8LEY3r8XROTzNXm9Phe31mgiIrEclq8776Ij1CUTBRdcu//hcWhjSzQA/crH +ICs0QvcfnmE3Cnt/irBm8h5CoWeMUz+cJnOhGLVX8lUIA9tKy1lbEy9To54AuLD9 +tTOh6oM4u2m2INFvBXQY8AeS0wKBgQDEO3DJBaNHqLZlJr1ZvqeLe6vzEvKq93Wb +kB/ts8YPXBJWpNmXVKVNVhmsHWh5XP7r0ew9bJ+1KGC/vxXLPZQUJhBaa6NG3AoW +PL+4U9xhNZLJtTEJufaKvXnV7QDVVHqhhDmSKjaUbCJd9EM2mSF465FZWklVpD6U +fO9SQ15KlwKBgQCcDy1Dg42wGjH1wzHds+1WYYs+deBCiFAVu8oPStH72Hg0jSa3 +q7EA7/Rhw2eH/s6R1Fzwe9aFjcDMk4Am2sgBpE/M5Ftysf2bSQGaLxAgml10JMvI +zBXG1YrqJVKqbQmmpWeCK4orjIi2sTpiMf76S0+8F7zkY/9saKxsDMsgQwKBgAuy +BzzT6zFgKs7IikyJAm9bxZnNLU1nRkkpQ93k8w8DS8yCMr3EO73qPcl2Tz28fy0K +6+uVR8eCSpHjD6d3WhYBVsQs2iRlBOziXgLcbKwWh1MiS3Pq83i9Zj+LyprsWAq8 +WLoPbgVWlI3I+yCL6+TLFXSf3vMNwPUUpSbgAQStAoGBAMpnDwAftoemigIasa2P ++JdoUPI8KdfjPf7aeTtNXBiPAaU0mPIMR3ATe7CgmsyZKrTQdvmmJBqANcXYFVzp +Oj/a68PoC07wJcZ8brEnPOot00M2q4a1xOl1lPcaXFDKDhJ//JqgcY4je/PuGYqE +OD6oEpRA9AM7kKZKyLZ0MvwK +-----END PRIVATE KEY----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.crt b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.crt new file mode 100644 index 000000000..7eb71175a --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUjCCAjoCCQCWsLZrpkFcFDANBgkqhkiG9w0BAQUFADBrMQswCQYDVQQGEwJO +TzENMAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEbMBkGA1UECgwSTm90IHJl +YWxseSBvc3RlbGNvMSEwHwYDVQQDDBgqLm5vdC1yZWFsbHktb3N0ZWxjby5vcmcw +HhcNMTgxMjIwMjE1MDM1WhcNMTkwMTE5MjE1MDM1WjBrMQswCQYDVQQGEwJOTzEN +MAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEbMBkGA1UECgwSTm90IHJlYWxs +eSBvc3RlbGNvMSEwHwYDVQQDDBgqLm5vdC1yZWFsbHktb3N0ZWxjby5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/D2xzqsSDezNbjb+falte21Ud +8KVbNv9vyk1rREIoV8BCJiYd7/LxUHhuOuWRxxhvMtBhBgFnzNFoAAcHSsauHoHo +ACytr7fICDHqqVAG6Ex6pKZloPSbsJjk1WTD200AG04iB03lYO5StN0uWcqj+YX+ +a4+6UtyRdrrWDiP0X0uDP0tMnJXtQHPQlUqc/i8jQhBwUGQhBYYVXhznM+4FDSDD +ERcm+GoY4n4WgEQ5qMTAVTxCs7n5B3BtLliBsnp5rfpBDwLDRIxu8ODSHuu5DiYZ +1/0IC2q3boSAjN/N0fne/MY9SNNTVaO02fw54xZ0lLem+9ymQaZHYT/B96XfAgMB +AAEwDQYJKoZIhvcNAQEFBQADggEBAL8EtMIiPhP9sRLs0uoEpr90CddeMUFY34Zr +AgADVfcijq0n6Djla6CrEnHJnnfjTIyOvMEk0s/GyaDyA3IGp1aCcvx0QC9pmPZu +ifgRVN/T+tEcoJrN/vuAW01HSn/q51JNkCLzHr6bFIaL02tGPWQmus0eqWTIdM4p +gD21PsYAIUlkzn5QNsVN5roOWu34QFKzo250hXpptDkfr9dst7A74OD5H/2Vv2/m +V0DBqwjlTL3IVrp1ZSrNdbh4MVCRa1JIHOOfvrzIaWOktxQmO0zeuiZUCKZ/D1cy +aBlQyHGRB9JZ8Rw84cJOeV9Sm9OOhYhW+LFcuitw2qkOh3bTPHw= +-----END CERTIFICATE----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.csr b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.csr new file mode 100644 index 000000000..2463f72df --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.csr @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIDEDCCAfgCAQAwazELMAkGA1UEBhMCTk8xDTALBgNVBAgMBE9zbG8xDTALBgNV +BAcMBE9zbG8xGzAZBgNVBAoMEk5vdCByZWFsbHkgb3N0ZWxjbzEhMB8GA1UEAwwY +Ki5ub3QtcmVhbGx5LW9zdGVsY28ub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAvw9sc6rEg3szW42/n2pbXttVHfClWzb/b8pNa0RCKFfAQiYmHe/y +8VB4bjrlkccYbzLQYQYBZ8zRaAAHB0rGrh6B6AAsra+3yAgx6qlQBuhMeqSmZaD0 +m7CY5NVkw9tNABtOIgdN5WDuUrTdLlnKo/mF/muPulLckXa61g4j9F9Lgz9LTJyV +7UBz0JVKnP4vI0IQcFBkIQWGFV4c5zPuBQ0gwxEXJvhqGOJ+FoBEOajEwFU8QrO5 ++QdwbS5YgbJ6ea36QQ8Cw0SMbvDg0h7ruQ4mGdf9CAtqt26EgIzfzdH53vzGPUjT +U1WjtNn8OeMWdJS3pvvcpkGmR2E/wfel3wIDAQABoGAwXgYJKoZIhvcNAQkOMVEw +TzAJBgNVHRMEAjAAMCMGA1UdEQQcMBqCGCoubm90LXJlYWxseS1vc3RlbGNvLm9y +ZzAdBgNVHQ4EFgQUsl83RnvhzD/imTivd30MMyTLjA8wDQYJKoZIhvcNAQEFBQAD +ggEBACoMTAL2tSslwHVUW9BzhHzm3m6NEo/m5gEKlN5NCknr6P4lkOb3+B90o7F8 +U3kzSDfbOpkoKeA/q2l7EjCee1Dyw9UkCQSsdrPA9soP0Ha1bweGlSwaPv1I0wx8 +0vlFXK/+mUH192CrCPxejB3BrvIfxfXJLAMXIGPxN8Aa9IU7AzOTjo+P5jKO2K9t +gk8NawKvjB3Udv5ZcHjg/z7hcLR3BGbd4d+bgvB/XYmj5gYvZ8Y6Lvv+kq2Dwsa4 +XaIWEvxxEreNvxrn6LUx1vlbFDg5mhh0AolYy7EN008SguxWzU7/uBwSweIUiFhA +M6Ge6SxY4D9Ii++Shk2/+LUYrO0= +-----END CERTIFICATE REQUEST----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.csr_config b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.csr_config new file mode 100644 index 000000000..92c5b1441 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.csr_config @@ -0,0 +1,60 @@ +# The main section is named req because the command we are using is req +# (openssl req ...) +[ req ] +# This specifies the default key size in bits. If not specified then 512 is +# used. It is used if the -new option is used. It can be overridden by using +# the -newkey option. +default_bits = 2048 + +# This is the default filename to write a private key to. If not specified the +# key is written to standard output. This can be overridden by the -keyout +# option. +default_keyfile = crypto-artefacts/sim-mgr/sk.key + +# If this is set to no then if a private key is generated it is not encrypted. +# This is equivalent to the -nodes command line option. For compatibility +# encrypt_rsa_key is an equivalent option. +encrypt_key = no + +# This option specifies the digest algorithm to use. Possible values include +# md5 sha1 mdc2. If not present then MD5 is used. This option can be overridden +# on the command line. +default_md = sha1 + +# if set to the value no this disables prompting of certificate fields and just +# takes values from the config file directly. It also changes the expected +# format of the distinguished_name and attributes sections. +prompt = no + +# if set to the value yes then field values to be interpreted as UTF8 strings, +# by default they are interpreted as ASCII. This means that the field values, +# whether prompted from a terminal or obtained from a configuration file, must +# be valid UTF8 strings. +utf8 = yes + +# This specifies the section containing the distinguished name fields to +# prompt for when generating a certificate or certificate request. +distinguished_name = not-really-ostelco.org + +# this specifies the configuration file section containing a list of extensions +# to add to the certificate request. It can be overridden by the -reqexts +# command line switch. See the x509v3_config(5) manual page for details of the +# extension section format. +req_extensions = my_extensions + +[ not-really-ostelco.org ] +C = NO +ST = Oslo +L = Oslo +O = Not really ostelco +CN = *.not-really-ostelco.org + +[ my_extensions ] +basicConstraints=CA:FALSE +subjectAltName=@my_subject_alt_names +subjectKeyIdentifier = hash + +[ my_subject_alt_names ] +DNS.1 = *.not-really-ostelco.org +# Multiple domains could be listed here, but we're not doing +# doing that now. diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.key b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.key new file mode 100644 index 000000000..86768b292 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sim-mgr/sk.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/D2xzqsSDezNb +jb+falte21Ud8KVbNv9vyk1rREIoV8BCJiYd7/LxUHhuOuWRxxhvMtBhBgFnzNFo +AAcHSsauHoHoACytr7fICDHqqVAG6Ex6pKZloPSbsJjk1WTD200AG04iB03lYO5S +tN0uWcqj+YX+a4+6UtyRdrrWDiP0X0uDP0tMnJXtQHPQlUqc/i8jQhBwUGQhBYYV +XhznM+4FDSDDERcm+GoY4n4WgEQ5qMTAVTxCs7n5B3BtLliBsnp5rfpBDwLDRIxu +8ODSHuu5DiYZ1/0IC2q3boSAjN/N0fne/MY9SNNTVaO02fw54xZ0lLem+9ymQaZH +YT/B96XfAgMBAAECggEALtfVOzavH51hJh1G0gr9g/A6mjCaGhcN4Za0DIybu9Bn +7s/zoHtoEQotvLjr+CXcM8c9l8wlJBHvdZQsJPmMZLxOFVeVnK/sWzrHIkWIvWjO +93LO6TPhRRqzIcfAANUPt+r56RXpX0e4psZ5RBf3uuQ+mfY9Mu2F7pQxkrG81vjp +l7j5txBLu2CQ3P/gTN7vEQDcArFj2wDbBLcE/3OQIxwAwiEVt+HQZ8Cg8IDLsvt9 +MPw0qLcJUd5rFw0BA0Lp6Hw+YWzIhVTQ24WzYjwu9Xoel70/UlZHoZ86C8AXeEyR +NVk8L3yWVxLFO0TccSQa3d4o6MOWfFdXLD/ckfwRAQKBgQDsQhrTdakOVIdRart2 +HEobHP0IDC37KM/UBH8IijxGb1I8zug+4sxUL4q3pMBkI84Cht0cU1maaGedx8bk +izESZJ2zEPchvIT7r5WgoFsTDDLN3nEz84VKr2t9pXPi50PVSdo9jQbjJWlugrwD +mxP+2zLdjEiWtAwlXWZwyTCVvwKBgQDPBniBBBpsdAKsiaenj9HG6ciuWVeFAQ6I +ne9uPuDYnR7R4mnZ2BhTMaizPmt+paPy5+/5G/ehHKQ0cmtW5e/k7/BeKuF8rtgx +mC+dHM4Wd06hLBvrLpBKWVkxomiIx6VhSTHN81ogTOCarw59VhXU1+JZc8kk74DW +09E+d+g34QKBgQCnRv67bfluvga5bLqiG9GUdOfrBMShqfnMggp3v3iB3lv51f22 +WNqXWLn84Nefj4JTY622WoV8wCNtIX//XVY3UaoemQqBhnsZwO5ONnuFdwOZo7+/ +KtLbWGzhH353z4rFv26YWfvgZCLMLiB05R45OnY/Sw4yNfTLl9/qR2jw/QKBgAgg +u+hszdDGOTim6uMkPVsu4Icf0NTS9swcT0MnytIWURhyaC96UXIqt/HZmITPYgFu +Y7iHBZDYvAWnHFm8C1AUr34y9slbX/eKfwwPDnRJWNfxEGOKX3Xbzimps6rzE6Yf +JopsbHRqMENCbjIziAXkN+nFJveBQ7CrfkKSmJZhAoGAd/BGDkDBVW0CNT/dc0r4 +h4oFgAooUECs/G8ui6P6c9WfO3gHLtbq2ZusY8cAEwHMFMM9Jo0cKWYf4jZvYm4O +G5NRDF+KHFK4GKd3KdcIfI/CX8JnxDRaTFUKxPCZ5PQlbsmrY3Gvgz7lGigXX3x0 +tWtImv8FnLRhE04IJdr5CfE= +-----END PRIVATE KEY----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.crt b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.crt new file mode 100644 index 000000000..4c5414af6 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVDCCAjwCCQCSZ2s+ZRa2uzANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJO +TzENMAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEcMBoGA1UECgwTTm90IHJl +YWxseSBTTURQIG9yZzEhMB8GA1UEAwwYKi5ub3QtcmVhbGx5LW9zdGVsY28ub3Jn +MB4XDTE4MTIyMDIxNTAzMloXDTE5MDExOTIxNTAzMlowbDELMAkGA1UEBhMCTk8x +DTALBgNVBAgMBE9zbG8xDTALBgNVBAcMBE9zbG8xHDAaBgNVBAoME05vdCByZWFs +bHkgU01EUCBvcmcxITAfBgNVBAMMGCoubm90LXJlYWxseS1vc3RlbGNvLm9yZzCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKJ017nuq7ziYF3Rmqk/5WJ1 +RH/wQEiitRkmkeHkKj+nguwI4kc4b96LbLqQnEULBudsGV7pJvG5ljOYkWDCY8MX +jC2lBE4UP68/KhTLKLrEV0ieKOsrRK2QdjCRYPbsgUFnO1vXI+cuFXvED9Z51DNR +aMaTSrsh5KovZKU2zcNVszvXWSpZlV0AjFAOpCYSZM/czgWBQ/VcFAG/igTsrpyY +3wA5x+D80tnp7QoEVlQe35ydhvixPPPhcM3be6XJ/rWgUpJyG/l7LGaeohqD5IiC +jJd1jyloWak+P0/I2YrD5ZzIqG+zIKdcML9okZXQpXgj1Jxf/l99QC0BhtKjp+MC +AwEAATANBgkqhkiG9w0BAQsFAAOCAQEAIWRGeLCTa7tYm+Qk+T72vNwdDrLcA7wS +PRy3Y7RFEi5/pUXRZNw/zIP08NZHGb65EXlH8uoBCI617EArVMd2EyPmrefmcdbP +jn6+OWuL36ZDf3KyiNfyi+as3CKT4JFmSFUcDtywwgwEMyr17Z/UDpr4MiN+xFQE +bj8PqS5en/FcrIzdV4Kf9xoVzYttDCggxBqz7BctBPBCHCUcnA7QaDYuQWpHVEOU +AN3lqliMAQlCAuAwHIC5QuWjK3ePrHD7wOcLXwndwP+sr1MFAraEUmrAM5LpfRrb +QSdUjCojBBGB2X3Q3LWpQh/EbjqBgjttJUQ8CKYK6EihJgIsUcVqAQ== +-----END CERTIFICATE----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.csr_config b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.csr_config new file mode 100644 index 000000000..aceccebcd --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.csr_config @@ -0,0 +1,60 @@ +# The main section is named req because the command we are using is req +# (openssl req ...) +[ req ] +# This specifies the default key size in bits. If not specified then 512 is +# used. It is used if the -new option is used. It can be overridden by using +# the -newkey option. +default_bits = 2048 + +# This is the default filename to write a private key to. If not specified the +# key is written to standard output. This can be overridden by the -keyout +# option. +default_keyfile = crypto-artefacts/sm-dp-plus/ca.key + +# If this is set to no then if a private key is generated it is not encrypted. +# This is equivalent to the -nodes command line option. For compatibility +# encrypt_rsa_key is an equivalent option. +encrypt_key = no + +# This option specifies the digest algorithm to use. Possible values include +# md5 sha1 mdc2. If not present then MD5 is used. This option can be overridden +# on the command line. +default_md = sha1 + +# if set to the value no this disables prompting of certificate fields and just +# takes values from the config file directly. It also changes the expected +# format of the distinguished_name and attributes sections. +prompt = no + +# if set to the value yes then field values to be interpreted as UTF8 strings, +# by default they are interpreted as ASCII. This means that the field values, +# whether prompted from a terminal or obtained from a configuration file, must +# be valid UTF8 strings. +utf8 = yes + +# This specifies the section containing the distinguished name fields to +# prompt for when generating a certificate or certificate request. +distinguished_name = not-really-smdp.org + +# this specifies the configuration file section containing a list of extensions +# to add to the certificate request. It can be overridden by the -reqexts +# command line switch. See the x509v3_config(5) manual page for details of the +# extension section format. +req_extensions = my_extensions + +[ not-really-smdp.org ] +C = NO +ST = Oslo +L = Oslo +O = Not really SMDP org +CN = *.not-really-ostelco.org + +[ my_extensions ] +basicConstraints=CA:FALSE +subjectAltName=@my_subject_alt_names +subjectKeyIdentifier = hash + +[ my_subject_alt_names ] +DNS.1 = *.not-really-ostelco.org +# Multiple domains could be listed here, but we're not doing +# doing that now. diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.key b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.key new file mode 100644 index 000000000..7f716832b --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCidNe57qu84mBd +0ZqpP+VidUR/8EBIorUZJpHh5Co/p4LsCOJHOG/ei2y6kJxFCwbnbBle6SbxuZYz +mJFgwmPDF4wtpQROFD+vPyoUyyi6xFdInijrK0StkHYwkWD27IFBZztb1yPnLhV7 +xA/WedQzUWjGk0q7IeSqL2SlNs3DVbM711kqWZVdAIxQDqQmEmTP3M4FgUP1XBQB +v4oE7K6cmN8AOcfg/NLZ6e0KBFZUHt+cnYb4sTzz4XDN23ulyf61oFKSchv5eyxm +nqIag+SIgoyXdY8paFmpPj9PyNmKw+WcyKhvsyCnXDC/aJGV0KV4I9ScX/5ffUAt +AYbSo6fjAgMBAAECggEAJ7TLLUSqcQYba5LZbFiTphbnYSXFcHtkK/uDWGS99sTo +eOxK8gFXRltpfcfuiemCDHodUVdHD3m+vmmhzrZ0T7CnsFhOzw6f6iNUE7T8BXoL +o1IUyjXPGWTfnktKGdAAX43tsirJOa3HznJDNLaeKNTS+QXzE/3at8XRoZfdfspF +zDWxsJcOYTElZrFtNdJ4z7sscnfZ3Eird2IrpB8fIdxHe2YPiLrXpOEToI6b5ocA +CkqZtmLy4sCFheMdf+IynxtnyF4b4HMBgtviPv5VUgXZDd92aWFDEK6YnnInesOu +basBmYMR6y7jBE21qOzJ19oltkzopF4e527rVDOw4QKBgQDOa1W26eowH22NQev3 +HVUBcCC6RPvULsqZxLxbJdwdM+CGElUqnL4jHS4rVvi02Q0zOxIwTZ5Jdi9Qr3is +DDmi1ZQr/q+ujyEzbjknEabNJNcdHVyg6W5BZKwtZgJ8ScqgTXtUNcGVyHrAhfe6 +v/4l16ZUffnwOmZ7QTMExaPCmwKBgQDJej5Z4W0UHKB5xIp/JVIbUuKnp8a74T/w +87FWudB99ZAlqgI65o1gP0NXCo8nzrRce3T2skmOrmLyNjiQdHVfh7r60nFUqpXH +I/ka1z8GL96HW8mHBkl8ggr4Gf8JpzVHt6x3N/um9oVpJCxPEuX7/R9HBhiXYMkb +tesKKu8AWQKBgDHHxuMW5Gh4m9XuKPbudvqizPG/AzB3nFqbDIW6yqusQCB1OV7O +cDhNqD3Berc6hSluvIMzpNG4k86UkriDNj8j3NkDUeD6GZqqoVPfuOdOVCZsV1Nj +GDjjC3bjXAQXU6t3JB/52tbBg4D8jfLWrHb5294Sh308yEw/PAuRkl2zAoGALU1f +V7ZoYG1PaBHZUl2B6MLqU+hVt0kep38kEOwXBTuB/fYMKlJM16dh7OBi8AB6bZEU +66OLBpoPhYbLkS+edKyAToWjFfaFVxGvoWlksm9xCd6JoeK4A1b6QG8X+YOvZ0DV +drkPzKsBtHJ9xAnrzI1NyxqDzQXmMmTlRJQCyYECgYEAwv50YOrb359D2HrJjwTE +10f8YXj8NtFtIJoYL0HRexI7+eSz5cMqlv0t203oDedP3VDWfhWQULZryTqE+foB +gA12NPa7/Yq0w2b132ubHxj7cpK6pJtJYFkyXeRgNiQWRzJAo5yVUKdjfcZ2+efL +ZE2uI4FDXBeWaBQnulIYBGE= +-----END PRIVATE KEY----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.srl b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.srl new file mode 100644 index 000000000..784e6f5de --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ca.srl @@ -0,0 +1 @@ +8E9CFB046D8BD64B diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.crt b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.crt new file mode 100644 index 000000000..bf1784f88 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVDCCAjwCCQCOnPsEbYvWSzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQGEwJO +TzENMAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEcMBoGA1UECgwTTm90IHJl +YWxseSBTTURQIG9yZzEhMB8GA1UEAwwYKi5ub3QtcmVhbGx5LW9zdGVsY28ub3Jn +MB4XDTE4MTIyMDIxNTAzNVoXDTE5MDExOTIxNTAzNVowbDELMAkGA1UEBhMCTk8x +DTALBgNVBAgMBE9zbG8xDTALBgNVBAcMBE9zbG8xHDAaBgNVBAoME05vdCByZWFs +bHkgU01EUCBvcmcxITAfBgNVBAMMGCoubm90LXJlYWxseS1vc3RlbGNvLm9yZzCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALSSDMpr5nDGgCI3kaA7uQov +QNcC9FejW8UlmBTY2NnllH7aoM5+OEiDgE1dYSE5pm+aq9SPCywWQft9HmE8VTBS +vYBThr0bO5k6r+5OF+HNyz2ZGd5+xev3dF1CyB7EKGeM2M9ngZgeQTQGXDWFGwy9 +WmGWMvgrddic+Jt+kI9VtKuWE3n6uuBEFu4AsxHnkUHZ6lorYCCfy97NuBg7ZzI0 +tL7y5hxAF1tPAXR/2mGHopRtS2rvfv+M8GXecbDmlNYGLCZHhv70O7fQJps33/fY +b9Lqq0t0MoYE5c6w23ai5W7aFp262jI40pFTR2/Oeu+fTiX0KDBYaytZnwHA2EkC +AwEAATANBgkqhkiG9w0BAQUFAAOCAQEAEH2TSqfRzQizTCPcrm5HsIh1q8O/7eRi +qY+geQXA1OJeKhVrQVD/3o/q9Sphm5haf4RMOCl+NtYL1k565eMRQs3isatZRoXu ++4tE/BKjs1MBCmg9qXqsgeEKaiXbyCoohLi3TbvTSR256jBCHAmJUQYSi7Sbb66d +8REFxpO37eQMRtSOJ8orO6KmrgYookuje48qGVRnHPeYeBJF8YrB7GHGVmxvJW9P +52TU/39VZzTQD+J87usb8W5gVhznfbDVbXFoQb2fzA2n6K3GeZ1az8ZgL8NmOKzP +7bZSkdbN77qTUZ2JlTOXQKi4wwKjfJJg3iBFLJ0zM1XZ7N8NHRDu7Q== +-----END CERTIFICATE----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.csr b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.csr new file mode 100644 index 000000000..002d97296 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.csr @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIDETCCAfkCAQAwbDELMAkGA1UEBhMCTk8xDTALBgNVBAgMBE9zbG8xDTALBgNV +BAcMBE9zbG8xHDAaBgNVBAoME05vdCByZWFsbHkgU01EUCBvcmcxITAfBgNVBAMM +GCoubm90LXJlYWxseS1vc3RlbGNvLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALSSDMpr5nDGgCI3kaA7uQovQNcC9FejW8UlmBTY2NnllH7aoM5+ +OEiDgE1dYSE5pm+aq9SPCywWQft9HmE8VTBSvYBThr0bO5k6r+5OF+HNyz2ZGd5+ +xev3dF1CyB7EKGeM2M9ngZgeQTQGXDWFGwy9WmGWMvgrddic+Jt+kI9VtKuWE3n6 +uuBEFu4AsxHnkUHZ6lorYCCfy97NuBg7ZzI0tL7y5hxAF1tPAXR/2mGHopRtS2rv +fv+M8GXecbDmlNYGLCZHhv70O7fQJps33/fYb9Lqq0t0MoYE5c6w23ai5W7aFp26 +2jI40pFTR2/Oeu+fTiX0KDBYaytZnwHA2EkCAwEAAaBgMF4GCSqGSIb3DQEJDjFR +ME8wCQYDVR0TBAIwADAjBgNVHREEHDAaghgqLm5vdC1yZWFsbHktb3N0ZWxjby5v +cmcwHQYDVR0OBBYEFODcizrGgaLphfX4HKePOMkHlFYeMA0GCSqGSIb3DQEBBQUA +A4IBAQBiLZORgEpcq49nKKY2j88k/OobgpU4mh7kyFacUJdmrQOXELLGwckwtW2O +O8ilCnWI79JbosDAC6YKepYaZU3tnQNH3R5gRp6N3UGj26PThCUFKBNLSuLc8DQu +ZANjleyoJRiJ1xckb7k7BSNvC4QtbQiSn5htQhvvGHsjtMmk3YPDos46UsnK6r4F +xsJRIdZ+PULbtbiLdtrDO7k/b6+hgtgFyy9fT139B3d6Z7eooRJ5RAbqMN3TxWFV +GzzZrW2lztvZRQF1iPngFuRghUcjtvPtQGnKKgqX+VzEvl8G9M1nNQClfua2BwfQ +aPjHNP5Gfj6iMxsazNzcTnV7315d +-----END CERTIFICATE REQUEST----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.csr_config b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.csr_config new file mode 100644 index 000000000..096dc0478 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.csr_config @@ -0,0 +1,60 @@ +# The main section is named req because the command we are using is req +# (openssl req ...) +[ req ] +# This specifies the default key size in bits. If not specified then 512 is +# used. It is used if the -new option is used. It can be overridden by using +# the -newkey option. +default_bits = 2048 + +# This is the default filename to write a private key to. If not specified the +# key is written to standard output. This can be overridden by the -keyout +# option. +default_keyfile = crypto-artefacts/sm-dp-plus/ck.key + +# If this is set to no then if a private key is generated it is not encrypted. +# This is equivalent to the -nodes command line option. For compatibility +# encrypt_rsa_key is an equivalent option. +encrypt_key = no + +# This option specifies the digest algorithm to use. Possible values include +# md5 sha1 mdc2. If not present then MD5 is used. This option can be overridden +# on the command line. +default_md = sha1 + +# if set to the value no this disables prompting of certificate fields and just +# takes values from the config file directly. It also changes the expected +# format of the distinguished_name and attributes sections. +prompt = no + +# if set to the value yes then field values to be interpreted as UTF8 strings, +# by default they are interpreted as ASCII. This means that the field values, +# whether prompted from a terminal or obtained from a configuration file, must +# be valid UTF8 strings. +utf8 = yes + +# This specifies the section containing the distinguished name fields to +# prompt for when generating a certificate or certificate request. +distinguished_name = not-really-smdp.org + +# this specifies the configuration file section containing a list of extensions +# to add to the certificate request. It can be overridden by the -reqexts +# command line switch. See the x509v3_config(5) manual page for details of the +# extension section format. +req_extensions = my_extensions + +[ not-really-smdp.org ] +C = NO +ST = Oslo +L = Oslo +O = Not really SMDP org +CN = *.not-really-ostelco.org + +[ my_extensions ] +basicConstraints=CA:FALSE +subjectAltName=@my_subject_alt_names +subjectKeyIdentifier = hash + +[ my_subject_alt_names ] +DNS.1 = *.not-really-ostelco.org +# Multiple domains could be listed here, but we're not doing +# doing that now. diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.key b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.key new file mode 100644 index 000000000..e23a6106c --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC0kgzKa+ZwxoAi +N5GgO7kKL0DXAvRXo1vFJZgU2NjZ5ZR+2qDOfjhIg4BNXWEhOaZvmqvUjwssFkH7 +fR5hPFUwUr2AU4a9GzuZOq/uThfhzcs9mRnefsXr93RdQsgexChnjNjPZ4GYHkE0 +Blw1hRsMvVphljL4K3XYnPibfpCPVbSrlhN5+rrgRBbuALMR55FB2epaK2Agn8ve +zbgYO2cyNLS+8uYcQBdbTwF0f9phh6KUbUtq737/jPBl3nGw5pTWBiwmR4b+9Du3 +0CabN9/32G/S6qtLdDKGBOXOsNt2ouVu2hadutoyONKRU0dvznrvn04l9CgwWGsr +WZ8BwNhJAgMBAAECggEAL2SZ6NMTI4xl+xUcqrKiKXMnkIdc/FeK8Z6hYO9dUyt3 +oyfcxvXH7fhdx3B32tGXxnkRGnQE2ahp3wkC3UCJl2pQBItntOCd7uTBYkmq6QAr +lNpHOOeLKRjEuOmbPr/7XtpP2XfwQq3GLVJyBzYNWHWmcDCM5if6AUWJ1r4MUSIb +aTAds+kXR2hR3fI4lwShEp5WVMIxCgCp2fBVDlLNIObCdTbNp4z9xdkngoXjbF4c +uN1Msc9l3Tghq55tjkzBr+tcfh7qSUHZtBzfqiL5nstg4mvmyKVgmrvHwb3PewG+ +W89Dvxs6T9htoo+GWBM0vWsHcC4Rub1+SC38oWmvVQKBgQDmyh/eOEceGb8hhivr +q9BHAsy73cj1lpIj7n2uKKF7VRBJEoKuJRuqzcT+bn6qKjeZLT0TfsbJQzMi3FcW +E/FouASObZCPXHOJWLB2EhuhzeuUsNLfka0IxDh82mKtjM2EkGTFGcTWeowm+vQd +v3CYEqZmr2zo+zyKi7ITlfKxVwKBgQDIS5VjpWepkTrWgTwh4gwJiC4Dj/KW4yWD +8WlHvBQ1AKtX3Wn4QvIo6aZylxOopPJZCqV8rf3xAUX7Bd+gH3nHXXdRh253hUVQ +k65zAnxBeKAy/VmxrCwy98XarmwEI9QjPuegF2fkVBUQ/ATt9IHUvrQ4z0CZce6P +DjrnEFSfXwKBgQC36/Shn6/taQ9MpCR0WCRPswd94C26qhgk0ncOSAsIwq2Lzlie +d6wo3ntTWLNQ4PwV8ltuIeZBlnA2I+qzCYmlrqDS8LX3yfG5Txixv5SNyhEoGhKz +YODIz7dEqLVjIYbXUks8WGDpnBf9KJlK67nLN3Gs+7iLo0yIDPQJb8JNWQKBgQCh +aTfUe6FUZzMxVihtbcshi1r5h/GJYzgCYnPjWVA3fniWcFpLtTeNfO2j/tfa2kJr +O0cteNHifJI2vv79/R+YaFwVmbyOGRpI2xqEmIYmBN2k+cJkikl8MWyC1Hk7xNva +I4Fp5DLXMGNhspcOZDKUjbKS7YIzpjsHkgIp32EypQKBgQDUvWInbjWi6kVGX88J +jXHdVu5W8C2AheewCY5uyQxgnFFif2C2JKGconnsp5FXAHVLLdF4iO7bxf4Z5GsI +fcBy4t7zjy9a2b18Wp/7Cq3OuCgtTaoi914w5bsmijI9F5Cm4vKihu7eCBDwqnEe +KVtyQV4VLt1cRb4FseKcgN7AVg== +-----END PRIVATE KEY----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.crt b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.crt new file mode 100644 index 000000000..a996cd48a --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVDCCAjwCCQCOnPsEbYvWSjANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQGEwJO +TzENMAsGA1UECAwET3NsbzENMAsGA1UEBwwET3NsbzEcMBoGA1UECgwTTm90IHJl +YWxseSBTTURQIG9yZzEhMB8GA1UEAwwYKi5ub3QtcmVhbGx5LW9zdGVsY28ub3Jn +MB4XDTE4MTIyMDIxNTAzNVoXDTE5MDExOTIxNTAzNVowbDELMAkGA1UEBhMCTk8x +DTALBgNVBAgMBE9zbG8xDTALBgNVBAcMBE9zbG8xHDAaBgNVBAoME05vdCByZWFs +bHkgU01EUCBvcmcxITAfBgNVBAMMGCoubm90LXJlYWxseS1vc3RlbGNvLm9yZzCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALSf81kKOsfADDWP2sIyPiHv +DCaVujseKTXfvg1W3Oyl4NbpRnsoQhjsvtCFGTZjjhCtzPLnSXmnkF8azO3uA/Y7 +BRAYtAhcgVA92nglkGUdEMK6/viMI8FfScvkFIqMIhYhOa6WAfThsCyCoSWMVlW8 +v82/hc5fkT5fX5m0s8F0Da87I02c/uKYrpYqM65mrCLkwkLtN5DJ9idd5VtsXpvJ +ZqqTGMhTAAYU7nKfPk+FA4cJjbcGFFBKLnOeY0H72EXzkgU2Lmo4Njwr6guS14Is +P9M55lf35p+iz2J5i6hBNEy0I8FbuFKdnZFFIVlmct9zTVQNmVziFhKgOyAmDz0C +AwEAATANBgkqhkiG9w0BAQUFAAOCAQEADDMWJbxSMMXgGAq2xfB0zohYk1qu9gVq +2yLXDmAF7bvW5Bg4eNhGQWan47gWTXfnb4xWHYTEaISxnz1kMjKfa5UPr6wRSPGa +h8DkDmB6TNqSZR97szV1/f/8NblsyXM/eN/hiVNAFaQu+3bHjZCTFL6K+BNymyvd +nH+Jz7I5SPurId1BUcev9/iivJp32g40kkP7GC1qiiCHD6JsmLe/H7useCWsRGPB +dprpM/dWjcS34+2Kb+bFEN2fHJB+msEQY3lYD/sUjX3CZ2/sV1LgxSZFh2bcDNeO +s4fHPWZCRAkDU6gr9cAkFPF92uDnBve0SK6f9eaJcRHi6cJshunupw== +-----END CERTIFICATE----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.csr b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.csr new file mode 100644 index 000000000..8674698d0 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.csr @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIDETCCAfkCAQAwbDELMAkGA1UEBhMCTk8xDTALBgNVBAgMBE9zbG8xDTALBgNV +BAcMBE9zbG8xHDAaBgNVBAoME05vdCByZWFsbHkgU01EUCBvcmcxITAfBgNVBAMM +GCoubm90LXJlYWxseS1vc3RlbGNvLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALSf81kKOsfADDWP2sIyPiHvDCaVujseKTXfvg1W3Oyl4NbpRnso +QhjsvtCFGTZjjhCtzPLnSXmnkF8azO3uA/Y7BRAYtAhcgVA92nglkGUdEMK6/viM +I8FfScvkFIqMIhYhOa6WAfThsCyCoSWMVlW8v82/hc5fkT5fX5m0s8F0Da87I02c +/uKYrpYqM65mrCLkwkLtN5DJ9idd5VtsXpvJZqqTGMhTAAYU7nKfPk+FA4cJjbcG +FFBKLnOeY0H72EXzkgU2Lmo4Njwr6guS14IsP9M55lf35p+iz2J5i6hBNEy0I8Fb +uFKdnZFFIVlmct9zTVQNmVziFhKgOyAmDz0CAwEAAaBgMF4GCSqGSIb3DQEJDjFR +ME8wCQYDVR0TBAIwADAjBgNVHREEHDAaghgqLm5vdC1yZWFsbHktb3N0ZWxjby5v +cmcwHQYDVR0OBBYEFKwhrfbGiy6T0lQmtdZjYuUwH/sDMA0GCSqGSIb3DQEBBQUA +A4IBAQAmJyhi4THJuSQHKsbRm2GXcWzYTp942K+VNAUxch89D5EQxRQWv1EJ+sgC +4OmDZWSipLO74J1OzOzV9brYB8NB8HEDQMZxPG1+2LhLkxvCCxyBB5/byILE3hbH +Xktej4/0djNcM6kEIi+oFxh1fn/WP2BZBxdv50Bwh+4sOjWQS/oS2/i3KITwZAxq +Fh/zlgMy4DXTErJUlIEhsb1dNM1zx7brYIH0IQV10mSrUlZViVNmN8jHLWvbSfgp +k7W8MbFq7bxqCqhKheHKFKRgF77xdaUAp7dNSiC6HpIBgHnLYC3eInSOYZoYYejW +kHsK+/BICa4sLnjW4abBGQM3oaIy +-----END CERTIFICATE REQUEST----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.csr_config b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.csr_config new file mode 100644 index 000000000..ae63bff45 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.csr_config @@ -0,0 +1,60 @@ +# The main section is named req because the command we are using is req +# (openssl req ...) +[ req ] +# This specifies the default key size in bits. If not specified then 512 is +# used. It is used if the -new option is used. It can be overridden by using +# the -newkey option. +default_bits = 2048 + +# This is the default filename to write a private key to. If not specified the +# key is written to standard output. This can be overridden by the -keyout +# option. +default_keyfile = crypto-artefacts/sm-dp-plus/sk.key + +# If this is set to no then if a private key is generated it is not encrypted. +# This is equivalent to the -nodes command line option. For compatibility +# encrypt_rsa_key is an equivalent option. +encrypt_key = no + +# This option specifies the digest algorithm to use. Possible values include +# md5 sha1 mdc2. If not present then MD5 is used. This option can be overridden +# on the command line. +default_md = sha1 + +# if set to the value no this disables prompting of certificate fields and just +# takes values from the config file directly. It also changes the expected +# format of the distinguished_name and attributes sections. +prompt = no + +# if set to the value yes then field values to be interpreted as UTF8 strings, +# by default they are interpreted as ASCII. This means that the field values, +# whether prompted from a terminal or obtained from a configuration file, must +# be valid UTF8 strings. +utf8 = yes + +# This specifies the section containing the distinguished name fields to +# prompt for when generating a certificate or certificate request. +distinguished_name = not-really-smdp.org + +# this specifies the configuration file section containing a list of extensions +# to add to the certificate request. It can be overridden by the -reqexts +# command line switch. See the x509v3_config(5) manual page for details of the +# extension section format. +req_extensions = my_extensions + +[ not-really-smdp.org ] +C = NO +ST = Oslo +L = Oslo +O = Not really SMDP org +CN = *.not-really-ostelco.org + +[ my_extensions ] +basicConstraints=CA:FALSE +subjectAltName=@my_subject_alt_names +subjectKeyIdentifier = hash + +[ my_subject_alt_names ] +DNS.1 = *.not-really-ostelco.org +# Multiple domains could be listed here, but we're not doing +# doing that now. diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.key b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.key new file mode 100644 index 000000000..104fabc39 --- /dev/null +++ b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC0n/NZCjrHwAw1 +j9rCMj4h7wwmlbo7Hik1374NVtzspeDW6UZ7KEIY7L7QhRk2Y44Qrczy50l5p5Bf +Gszt7gP2OwUQGLQIXIFQPdp4JZBlHRDCuv74jCPBX0nL5BSKjCIWITmulgH04bAs +gqEljFZVvL/Nv4XOX5E+X1+ZtLPBdA2vOyNNnP7imK6WKjOuZqwi5MJC7TeQyfYn +XeVbbF6byWaqkxjIUwAGFO5ynz5PhQOHCY23BhRQSi5znmNB+9hF85IFNi5qODY8 +K+oLkteCLD/TOeZX9+afos9ieYuoQTRMtCPBW7hSnZ2RRSFZZnLfc01UDZlc4hYS +oDsgJg89AgMBAAECggEAY44NuLP0tghaMmN5tbOvn1B8p/+6x77WBAWwYPXRivXa +uVmWKWeFFuvWOGREA0BYG0VQQ0nLq6v53RGOhk28DUl2fuq+wCUMeUe+VJ6PucuN +Y2diBWhohLqamDC8Saj3WK8zddDkfy6txbqyS1nQdC02opa/j4IJMhGrRbLdqO/5 +7Lybfofa7vrmhYoiqpN1rBWV28FEcjTbfgJ4MxeUeAAsIzo53knVRv9I6CgQvbRa +ujXkJBUrAT8nPbZW+mYKP3Kc/zoMhEhEFxzvYpTU3/5O2W+68Wqbd4LX22+LnkHt +a9DwFmCPhbByoALbeOI/WQGRg1a3xVLolVdDh0aegQKBgQDv3aygmmUofFLpNoWT +m8VUHJOSZTPLG2TNhhUJe6aeiUyeMcVI6IEF8aqdVdbxZ0HYmK8NbO3bSZJXzPce +FhD7YFJzqRka0v8UaGV1gw/4TDR303XBZNdA+JyXH4klN9juoAeN4xATu692DLEg +VA6/2DBTlpx1/a5z51o/96hesQKBgQDAxi9qDqkQkH+l0ZlQFTHj9JrLP4vsUD/h ++s8dhJPKFQjEMg7ZfrNn/qPHmTJRJaD7zEKdeWm3dQGgN0ou9JL+FRe1+3PPaRhG +EzYsMQ4Q+HUsn5q5g/LJjghzGLfoln8fjDoWRvP9jyYcoPykOWunqnwty51PrhrF +yXj5QUfUTQKBgAF5mH0oVeTo1s2uUyX0OENrJZEp5CaklXsaDvkO9JhW+cyjO7ZW +D60MrmLnSzoSy3ncfn8To2bMzgSSMxbRRet1zSv+5zOeBomGltEhLDD3rv7povi8 +eQJiRPw86mf7Lu9QtpstwUSNy+dq7o/nVGvjlXB+JZooJDF8Q7bO/A8RAoGAGZEv +cK2JFr1TcLaf0tM8zrL+ZL6E3E64akxNc+jFgSPRCdRpy8bWHJDVP/+9gK2w8DRj +EWes8bv+/zTWDew6IqDBiE1VSsjxgznBEZNf/jg1sjlo1/n8FWdVD47TtXFgYtrC +SXoXmiWGNH3VhCJpeM9PsPM0ZgD9ZAYYmVZjJHECgYAlCadF0LQqRTPElH+2aydb +jnp1nuUHp/Lva/+b5Z1fmgEIo91PR2cTAaRJq8j8WD89olkamd9rsnnz54l7LhA9 +BHOT7wqsTiyLjvrgimcG+31Pj3l1D1T9WCcIerj7XjSvUksolRoB66YE5tkr8Pbn +Hj5909XyK8lwU83X4E0rAA== +-----END PRIVATE KEY----- diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk_keys.jks b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk_keys.jks new file mode 100644 index 000000000..8e2775bf1 Binary files /dev/null and b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk_keys.jks differ diff --git a/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk_trust.jks b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk_trust.jks new file mode 100644 index 000000000..f577b75c9 Binary files /dev/null and b/sim-administration/certificate-authority-simulated/crypto-artefacts/sm-dp-plus/sk_trust.jks differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/ICON_JPG.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/ICON_JPG.jpg new file mode 100644 index 000000000..4085fcd7f Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/ICON_JPG.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1.jpg new file mode 100644 index 000000000..17777cca1 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1.png new file mode 100644 index 000000000..bd17ece52 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1_2_SEG.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1_2_SEG.png new file mode 100644 index 000000000..774442937 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O1_2_SEG.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O2.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O2.jpg new file mode 100644 index 000000000..a3ffc6a25 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O2.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O2.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O2.png new file mode 100644 index 000000000..3ed4eeef7 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O2.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O3.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O3.jpg new file mode 100644 index 000000000..9076fa49c Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O3.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O3.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O3.png new file mode 100644 index 000000000..fb1a61163 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O3.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O4.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O4.jpg new file mode 100644 index 000000000..cebb9acb8 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O4.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O4.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O4.png new file mode 100644 index 000000000..2ec512582 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O4.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O5.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O5.jpg new file mode 100644 index 000000000..5b394c3d0 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O5.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O5.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O5.png new file mode 100644 index 000000000..ccc5ce512 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O5.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O6.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O6.jpg new file mode 100644 index 000000000..8c0a70def Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O6.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O6.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O6.png new file mode 100644 index 000000000..c629b8c41 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O6.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O7.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O7.jpg new file mode 100644 index 000000000..61b6691b1 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O7.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O7.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O7.png new file mode 100644 index 000000000..0fa08e1ec Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O7.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O8.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O8.jpg new file mode 100644 index 000000000..3059e3273 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O8.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O8.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O8.png new file mode 100644 index 000000000..0a69d1044 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O8.png differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O9.jpg b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O9.jpg new file mode 100644 index 000000000..76bc64244 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O9.jpg differ diff --git a/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O9.png b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O9.png new file mode 100644 index 000000000..5d8923ee0 Binary files /dev/null and b/sim-administration/docs/AnnexH_SGP.23_Icons_final/profile_O9.png differ diff --git a/sim-administration/docs/SGP.22_v2.2.pdf b/sim-administration/docs/SGP.22_v2.2.pdf new file mode 100644 index 000000000..1ad1e3188 Binary files /dev/null and b/sim-administration/docs/SGP.22_v2.2.pdf differ diff --git a/sim-administration/docs/SGP.23-v1.3.pdf b/sim-administration/docs/SGP.23-v1.3.pdf new file mode 100644 index 000000000..08cf5145f Binary files /dev/null and b/sim-administration/docs/SGP.23-v1.3.pdf differ diff --git a/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_1.png b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_1.png new file mode 100644 index 000000000..077a44b04 Binary files /dev/null and b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_1.png differ diff --git a/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_2.png b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_2.png new file mode 100644 index 000000000..8e55b7aa9 Binary files /dev/null and b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_2.png differ diff --git a/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_3.png b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_3.png new file mode 100644 index 000000000..077c490d5 Binary files /dev/null and b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_3.png differ diff --git a/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_3_NO_CC.png b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_3_NO_CC.png new file mode 100644 index 000000000..170e40d0c Binary files /dev/null and b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_3_NO_CC.png differ diff --git a/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_4.png b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_4.png new file mode 100644 index 000000000..9bfe76a00 Binary files /dev/null and b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_4.png differ diff --git a/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_5.png b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_5.png new file mode 100644 index 000000000..b319e432c Binary files /dev/null and b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_5.png differ diff --git a/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_INVALID_FORMAT.png b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_INVALID_FORMAT.png new file mode 100644 index 000000000..becbfb37b Binary files /dev/null and b/sim-administration/docs/SGP.23_AnnexH_QRCodes/ACTIVATION_CODE_INVALID_FORMAT.png differ diff --git a/sim-administration/docs/esim.pdf b/sim-administration/docs/esim.pdf new file mode 100644 index 000000000..1ad1e3188 Binary files /dev/null and b/sim-administration/docs/esim.pdf differ diff --git a/sim-administration/es2plus4dropwizard/build.gradle b/sim-administration/es2plus4dropwizard/build.gradle new file mode 100644 index 000000000..82395f4e2 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/build.gradle @@ -0,0 +1,28 @@ +plugins { + id "org.jetbrains.kotlin.jvm" +} + +dependencies { + implementation project(":sim-administration:jersey-json-schema-validator") + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "io.swagger.core.v3:swagger-core:$swaggerVersion" + implementation "io.swagger.core.v3:swagger-jaxrs2:$swaggerVersion" + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" + testImplementation "javax.xml.bind:jaxb-api:$jaxbVersion" + testImplementation "javax.activation:activation:1.1.1" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +apply from: '../../gradle/jacoco.gradle' \ No newline at end of file diff --git a/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusClient.kt b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusClient.kt new file mode 100644 index 000000000..73b9b7ba8 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusClient.kt @@ -0,0 +1,309 @@ +package org.ostelco.sim.es2plus + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.http.HttpResponse +import org.apache.http.client.HttpClient + +import org.apache.http.client.methods.HttpPost +import org.apache.http.entity.StringEntity +import javax.validation.Valid +import javax.validation.constraints.NotNull +import javax.ws.rs.client.Client +import javax.ws.rs.client.Entity +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + + +/** + * A httpClient implementation that is able to talk all of the GSMA specified parts of + * the ES2+ protocol. + */ +class ES2PlusClient( + private val requesterId: String, + private val host: String = "127.0.0.1", + private val port: Int = 8443, + private val httpClient: HttpClient? = null, + private val jerseyClient: Client? = null) { + + companion object { + const val X_ADMIN_PROTOCOL_HEADER_VALUE = "gsma/rsp/v2.0.0" + } + + /* For test cases where content should be returned. */ + @Throws(ES2PlusClientException::class) + private fun postEs2ProtocolCmd( + path: String, + es2ProtocolPayload: T, + sclass: Class, + expectedReturnCode: Int = 200): S { + + /// XXX TODO: + // We currently need jersey client for integration test and httpClient for functional + // SSL. This is unfortunate, but also seems to be the shortest path towards a + // functioning & testable ES2+ client. Should be implemented using two different + // methods, that should then share a lot of common code. + + if (httpClient != null) { + + val objectMapper = ObjectMapper() + val payload = objectMapper.writeValueAsString(es2ProtocolPayload) + + val req = HttpPost("https://%s:%d%s".format(host, port, path)) + + req.setHeader("User-Agent", "gsma-rsp-lpad") + req.setHeader("X-Admin-Protocol", X_ADMIN_PROTOCOL_HEADER_VALUE) + req.setHeader("Content-Type", MediaType.APPLICATION_JSON) + req.setHeader("Accept", "application/json") + req.setHeader("Content-type", "application/json") + req.entity = StringEntity(payload) + + val result: HttpResponse = httpClient.execute(req) ?: throw ES2PlusClientException("Null response from http httpClient") + + // Validate returned response + val statusCode = result.statusLine.statusCode + if (expectedReturnCode != statusCode) { + + val msg = "Expected return value $expectedReturnCode, but got $statusCode. Body was \"${result.entity.content}\"" + throw ES2PlusClientException(msg) + } + + val xAdminProtocolHeader = result.getFirstHeader("X-Admin-Protocol") + ?: throw ES2PlusClientException("Expected header X-Admin-Protocol to be non null") + + val protocolVersion = xAdminProtocolHeader.value + + if (protocolVersion != X_ADMIN_PROTOCOL_HEADER_VALUE) { + throw ES2PlusClientException("Expected header X-Admin-Protocol to be '$X_ADMIN_PROTOCOL_HEADER_VALUE' but it was '$xAdminProtocolHeader'") + } + + val returnedContentType = result.getFirstHeader("Content-Type")!! + val expectedContentType = "application/json" + + if (returnedContentType.value != expectedContentType) { + throw ES2PlusClientException("Expected header Content-Type to be '$expectedContentType' but was '$returnedContentType'") + } + + return objectMapper.readValue(result.entity.content, sclass) ?: throw ES2PlusClientException("null return value") + } else if (jerseyClient != null) { + val entity: Entity = Entity.entity(es2ProtocolPayload, MediaType.APPLICATION_JSON) + val result: Response = jerseyClient.target(path) + .request(MediaType.APPLICATION_JSON) + .header("User-Agent", "gsma-rsp-lpad") + .header("X-Admin-Protocol", X_ADMIN_PROTOCOL_HEADER_VALUE) + .post(entity) + + // Validate returned response + if (expectedReturnCode != result.status) { + val msg = "Expected return value $expectedReturnCode, but got ${result.status}. Body was \"${result.readEntity(String::class.java)}\"" + throw ES2PlusClientException(msg) + } + + val xAdminProtocolHeader = result.getHeaderString("X-Admin-Protocol") + + if (xAdminProtocolHeader == null || xAdminProtocolHeader != X_ADMIN_PROTOCOL_HEADER_VALUE) { + throw ES2PlusClientException("Expected header X-Admin-Protocol to be '$X_ADMIN_PROTOCOL_HEADER_VALUE' but it was '$xAdminProtocolHeader'") + } + + val returnedContentType = result.getHeaderString("Content-Type") + val expectedContentType = "application/json" + + if (returnedContentType == null || returnedContentType != expectedContentType) { + throw ES2PlusClientException("Expected header Content-Type to be '$expectedContentType' but was '$returnedContentType'") + } + return result.readEntity(sclass) + } else { + throw RuntimeException("No jersey nor apache http client, bailing out!!") + } + } + + /* For cases where no content should be returned. Currently only + used in 'progress-download' test. */ + @Throws(ES2PlusClientException::class) + private fun postEs2ProtocolCmdNoContentReturned( + path: String, + es2ProtocolPayload: T, + expectedReturnCode: Int = 204) { + if (httpClient != null) { + + val objectMapper = ObjectMapper() + val payload = objectMapper.writeValueAsString(es2ProtocolPayload) + + val req = HttpPost("https://%s:%d%s".format(host, port, path)) + + req.setHeader("User-Agent", "gsma-rsp-lpad") + req.setHeader("X-Admin-Protocol", X_ADMIN_PROTOCOL_HEADER_VALUE) + req.setHeader("Content-Type", MediaType.APPLICATION_JSON) + req.setHeader("Accept", "application/json") + req.setHeader("Content-type", "application/json") + req.entity = StringEntity(payload) + + val result: HttpResponse = httpClient.execute(req) ?: throw ES2PlusClientException("Null response from http httpClient") + + // Validate returned response + val statusCode = result.statusLine.statusCode + if (expectedReturnCode != statusCode) { + val msg = "Expected return value $expectedReturnCode, but got $statusCode." + throw ES2PlusClientException(msg) + } + } else if (jerseyClient != null) { + val entity: Entity = Entity.entity(es2ProtocolPayload, MediaType.APPLICATION_JSON) + val result: Response = jerseyClient.target(path) + .request(MediaType.APPLICATION_JSON) + .header("User-Agent", "gsma-rsp-lpad") + .header("X-Admin-Protocol", X_ADMIN_PROTOCOL_HEADER_VALUE) + .post(entity) + + // Validate returned response + if (expectedReturnCode != result.status) { + val msg = "Expected return value $expectedReturnCode, but got ${result.status}." + throw ES2PlusClientException(msg) + } + } else { + throw RuntimeException("No jersey nor apache http client, bailing out!!") + } + } + + fun profileStatus( + iccidList: List): Es2ProfileStatusResponse { + + val wrappedIccidList = iccidList.map { IccidListEntry(iccid=it) } + + val es2ProtocolPayload = Es2PlusProfileStatus( + header = ES2RequestHeader( + functionRequesterIdentifier = requesterId, + functionCallIdentifier = "profileStatus"), + iccidList = wrappedIccidList) + + return postEs2ProtocolCmd( + "/gsma/rsp2/es2plus/getProfileStatus", + es2ProtocolPayload, + Es2ProfileStatusResponse::class.java, + expectedReturnCode = 200) + } + + fun downloadOrder( + eid: String? = null, + iccid: String, + profileType: String?=null): Es2DownloadOrderResponse { + val es2ProtocolPayload = Es2PlusDownloadOrder( + header = ES2RequestHeader( + functionRequesterIdentifier = requesterId, + functionCallIdentifier = "downloadOrder"), + eid = eid, + iccid = iccid, + profileType = profileType) + + return postEs2ProtocolCmd( + "/gsma/rsp2/es2plus/downloadOrder", + es2ProtocolPayload, + Es2DownloadOrderResponse::class.java, + expectedReturnCode = 200) + } + + fun confirmOrder(eid: String? = null, + iccid: String, + matchingId: String? = null, + confirmationCode: String? = null, + smdpAddress: String? = null, + releaseFlag: Boolean): Es2ConfirmOrderResponse { + val es2ProtocolPayload = + Es2ConfirmOrder( + header = ES2RequestHeader( + functionRequesterIdentifier = requesterId, + functionCallIdentifier = "confirmOrder"), + eid = eid, + iccid = iccid, + matchingId = matchingId, + confirmationCode = confirmationCode, + smdpAddress = smdpAddress, + releaseFlag = releaseFlag) + return postEs2ProtocolCmd( + "/gsma/rsp2/es2plus/confirmOrder", + es2ProtocolPayload = es2ProtocolPayload, + expectedReturnCode = 200, + sclass = Es2ConfirmOrderResponse::class.java) + } + + fun cancelOrder(iccid: String, finalProfileStatusIndicator: String, eid: String? = null,matchingId: String? = null): HeaderOnlyResponse { + return postEs2ProtocolCmd("/gsma/rsp2/es2plus/cancelOrder", + es2ProtocolPayload = Es2CancelOrder( + header = ES2RequestHeader( + functionRequesterIdentifier = requesterId, + functionCallIdentifier = "cancelOrder" + ), + iccid = iccid, + eid = eid, + matchingId = matchingId, + finalProfileStatusIndicator = finalProfileStatusIndicator), + sclass = HeaderOnlyResponse::class.java, + expectedReturnCode = 200) + } + + fun releaseProfile(iccid: String): HeaderOnlyResponse { + return postEs2ProtocolCmd("/gsma/rsp2/es2plus/releaseProfile", + Es2ReleaseProfile( + header = ES2RequestHeader( + functionRequesterIdentifier = requesterId, + functionCallIdentifier = "releaseProfile" + ), + iccid = iccid), + sclass = HeaderOnlyResponse::class.java, + expectedReturnCode = 200) + } + + fun handleDownloadProgressInfo( + eid: String? = null, + iccid: String, + profileType: String, + timestamp: String, + notificationPointId: Int, + notificationPointStatus: ES2NotificationPointStatus, + resultData: String? = null, + imei: String? = null + ) { + postEs2ProtocolCmdNoContentReturned("/gsma/rsp2/es2plus/handleDownloadProgressInfo", + Es2HandleDownloadProgressInfo( + header = ES2RequestHeader( + functionRequesterIdentifier = requesterId, + functionCallIdentifier = "handleDownloadProgressInfo" + ), + eid = eid, + iccid = iccid, + profileType = profileType, + timestamp = timestamp, + notificationPointId = notificationPointId, + notificationPointStatus = notificationPointStatus, + resultData = resultData, + imei = imei), + expectedReturnCode = 204) + } +} + + +/** + * Thrown when something goes wrong with the ES2+ protocol. + */ +class ES2PlusClientException(msg: String) : Exception(msg) + + +/** + * Configuration class to be used in application's config + * when a client is necessary. + */ +class EsTwoPlusConfig { + @Valid + @NotNull + @JsonProperty("requesterId") + var requesterId: String = "" + + @Valid + @NotNull + @JsonProperty("host") + var host: String = "" + + @Valid + @NotNull + @JsonProperty("port") + var port: Int = 4711 +} diff --git a/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusEntities.kt b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusEntities.kt new file mode 100644 index 000000000..e83b7a2f7 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusEntities.kt @@ -0,0 +1,275 @@ +package org.ostelco.sim.es2plus + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import org.ostelco.jsonschema.JsonSchema + + +/// +/// The fields that all requests needs to have in their headers +/// (for reasons that are unclear to me) +/// + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ES2RequestHeader( + @JsonProperty("functionRequesterIdentifier") val functionRequesterIdentifier: String, + @JsonProperty("functionCallIdentifier") val functionCallIdentifier: String +) + +/// +/// The fields all responses needs to have in their headers +/// (also unknown to me :) +/// + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ES2ResponseHeader( + @JsonProperty("functionExecutionStatus") val functionExecutionStatus: FunctionExecutionStatus = FunctionExecutionStatus()) + +@JsonInclude(JsonInclude.Include.NON_NULL) +enum class FunctionExecutionStatusType { + @JsonProperty("Executed-Success") + ExecutedSuccess, + @JsonProperty("Executed-WithWarning") + ExecutedWithWarning, + @JsonProperty("Failed") + Failed, + @JsonProperty("Expired") + Expired +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class FunctionExecutionStatus( + @JsonProperty("status") val status: FunctionExecutionStatusType = FunctionExecutionStatusType.ExecutedSuccess, + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("statusCodeData") val statusCodeData: StatusCodeData? = null) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class StatusCodeData( + @JsonProperty("subjectCode") var subjectCode: String, + @JsonProperty("reasonCode") var reasonCode: String, + @JsonProperty("subjectIdentifier") var subjectIdentifier: String? = null, + @JsonProperty("message") var message: String? = null) + +/// +/// The DownloadOrder function +/// + +@JsonSchema("ES2+DownloadOrder-def") +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2PlusDownloadOrder( + @JsonProperty("header") val header: ES2RequestHeader, + @JsonProperty("eid") val eid: String? = null, + @JsonProperty("iccid") val iccid: String? = null, + @JsonProperty("profileType") val profileType: String? = null +) + +@JsonSchema("ES2+DownloadOrder-response") +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2DownloadOrderResponse( + @JsonProperty("header") val header: ES2ResponseHeader = eS2SuccessResponseHeader(), + @JsonProperty("iccid") val iccid: String? = null +) + + +/// +/// The CancelOrder function +/// + + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2PlusCancelOrder( + @JsonProperty("header") val header: ES2RequestHeader, + @JsonProperty("iccid") val iccid: String? = null, + @JsonProperty("finalProfileStatusIndicator") val finalProfileStatusIndicator: String? = null +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2PlusCancelOrderResponse( + @JsonProperty("header") val header: ES2RequestHeader, + @JsonProperty("iccid") val iccid: String? = null, + @JsonProperty("finalProfileStatusIndicator") val finalProfileStatusIndicator: String? = null +) + +/// +/// The ProfileStatus function +/// + + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2PlusProfileStatus( + @JsonProperty("header") val header: ES2RequestHeader, + @JsonProperty("iccidList") val iccidList: List = listOf() +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class IccidListEntry( + @JsonProperty("iccid") val iccid: String? +) + + + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2ProfileStatusResponse( + @JsonProperty("header") val header: ES2ResponseHeader = eS2SuccessResponseHeader(), + @JsonProperty("profileStatusList") val profileStatusList: List? = listOf(), + @JsonProperty("completionTimestamp") val completionTimestamp: String? +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ProfileStatus( + @JsonProperty("status_last_update_timestamp") val lastUpdateTimestamp:String? = null, + @JsonProperty("profileStatusList") val profileStatusList: List? = listOf(), + @JsonProperty("acToken") val acToken: String? = null, + @JsonProperty("state") val state: String? = null, + @JsonProperty("eid") val eid: String? = null, + @JsonProperty("iccid") val iccid: String? = null, + @JsonProperty("lockFlag") val lockFlag: Boolean? = null +) + + +/// +/// The ConfirmOrder function +/// + +@JsonSchema("ES2+ConfirmOrder-def") +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2ConfirmOrder( + @JsonProperty("header") val header: ES2RequestHeader, + @JsonProperty("eid") val eid: String? = null, + @JsonProperty("iccid") val iccid: String, + @JsonProperty("matchingId") val matchingId: String? = null, + @JsonProperty("confirmationCode") val confirmationCode: String? = null, + @JsonProperty("smdpAddress") val smdpAddress: String? = null, + @JsonProperty("releaseFlag") val releaseFlag: Boolean +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonSchema("ES2+ConfirmOrder-response") +data class Es2ConfirmOrderResponse( + @JsonProperty("header") val header: ES2ResponseHeader = eS2SuccessResponseHeader(), + @JsonProperty("eid") val eid: String? = null, + @JsonProperty("matchingId") val matchingId: String? = null, + @JsonProperty("smdpAddress") val smdsAddress: String? = null +) + +/// +/// The CancelOrder function +/// + +@JsonInclude(JsonInclude.Include.NON_NULL) +// XXX CXHeck @JsonSchema("ES2+CancelOrder-def") +data class Es2CancelOrder( + @JsonProperty("header") val header: ES2RequestHeader, + @JsonProperty("eid") val eid: String?=null, + @JsonProperty("profileStatusList") val profileStatusList: String? = null, + @JsonProperty("matchingId") val matchingId: String? = null, + @JsonProperty("iccid") val iccid: String?=null, + @JsonProperty("finalProfileStatusIndicator") val finalProfileStatusIndicator: String? = null +) + +@JsonSchema("ES2+HeaderOnly-response") +data class HeaderOnlyResponse(@JsonProperty("header") val header: ES2ResponseHeader = eS2SuccessResponseHeader()) + + +/// +/// The ReleaseProfile function +/// + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonSchema("ES2+ReleaseProfile-def") +data class Es2ReleaseProfile( + @JsonProperty("header") val header: ES2RequestHeader, + @JsonProperty("iccid") val iccid: String +) + + +/// +/// The The HandleDownloadProgressInfo function +/// + + +@JsonSchema("ES2+HandleDownloadProgressInfo-def") +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Es2HandleDownloadProgressInfo( + val header: ES2RequestHeader, + val eid: String? = null, + val iccid: String, + val profileType: String, + val timestamp: String, + val notificationPointId: Int, + val notificationPointStatus: ES2NotificationPointStatus, + val resultData: String? = null, + val tac: String? = null, + val imei: String? = null, + // This field is added to ensure that the function signature of the primary and the actual + // constructors are not confused by the JVM. It is ignored by all business logic. + private val ignoreThisField : String? = null) { + + + // If the stored ICCID contains a trailing "F", which it may because some vendors insist + // on contaminating their ICCID values in this way, then we simply rewrite the value + // before storing it in the data object. + // Note that this is a bad practice, it's probably much better to rewrite the + // input field before it hits the data class, but I don't know how to do that + // so this kludge is used instead. The good thing about the current fix is that + // it preserves external interfaces so the damage is contained within this + // class. + + @JsonCreator + constructor (@JsonProperty("header") header: ES2RequestHeader, + @JsonProperty("eid") eid: String? = null, + @JsonProperty("iccid") iccid: String, + @JsonProperty("profileType") profileType: String, + @JsonProperty("timestamp") timestamp: String, + @JsonProperty("tac") tac: String? = null, + @JsonProperty("notificationPointId") notificationPointId: Int, + @JsonProperty("notificationPointStatus") notificationPointStatus: ES2NotificationPointStatus, + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("resultData") resultData: String? = null, + @JsonProperty("imei") imei: String? = null) : this( + header = header, + eid = eid, + iccid = if (!iccid.endsWith("F")) { // Rewrite input value if necessary + iccid + } else { + iccid.dropLast(1) + }, + tac = tac, + profileType = profileType, + timestamp = timestamp, + notificationPointId = notificationPointId, + notificationPointStatus = notificationPointStatus, + resultData = resultData, + imei = imei, + ignoreThisField = null //Field is always ignored, but necessary to avoid recursion + ) +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ES2NotificationPointStatus( + @JsonProperty("status") val status: FunctionExecutionStatusType = FunctionExecutionStatusType.ExecutedSuccess, + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("statusCodeData") val statusCodeData: ES2StatusCodeData? = null +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ES2StatusCodeData( + @JsonProperty("subjectCode") val subjectCode: String, // "Executed-Success, Executed-WithWarning, Failed or + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("reasonCode") val statusCodeData: String, + @JsonProperty("subjectIdentifier") val subjectIdentifier: String? = null, + @JsonProperty("message") val message: String? = null +) + +/// +/// Convenience functions to generate headers +/// + +fun newErrorHeader(e: SmDpPlusException): ES2ResponseHeader { + return ES2ResponseHeader( + functionExecutionStatus = + FunctionExecutionStatus( + status = FunctionExecutionStatusType.Failed, + statusCodeData = e.statusCodeData)) +} + +fun eS2SuccessResponseHeader() = + ES2ResponseHeader(functionExecutionStatus = + FunctionExecutionStatus(status = FunctionExecutionStatusType.ExecutedSuccess)) \ No newline at end of file diff --git a/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusResources.kt b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusResources.kt new file mode 100644 index 000000000..93c26d6ac --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusResources.kt @@ -0,0 +1,196 @@ +package org.ostelco.sim.es2plus + +import io.dropwizard.jersey.setup.JerseyEnvironment +import org.ostelco.jsonschema.DynamicES2ValidatorAdder +import org.ostelco.sim.es2plus.ES2PlusClient.Companion.X_ADMIN_PROTOCOL_HEADER_VALUE +import org.ostelco.sim.es2plus.SmDpPlusServerResource.Companion.ES2PLUS_PATH_PREFIX +import org.slf4j.LoggerFactory +import java.io.IOException +import javax.ws.rs.Consumes +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.container.ContainerRequestContext +import javax.ws.rs.container.ContainerRequestFilter +import javax.ws.rs.container.ContainerResponseContext +import javax.ws.rs.container.ContainerResponseFilter +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response +import javax.ws.rs.ext.ExceptionMapper +import javax.ws.rs.ext.Provider + + +@Provider +class ES2PlusIncomingHeadersFilter : ContainerRequestFilter { + + companion object { + fun addEs2PlusDefaultFiltersAndInterceptors(env: JerseyEnvironment) { + + // XXX Replace these with dynamic adders + env.register(ES2PlusIncomingHeadersFilter()) + env.register(ES2PlusOutgoingHeadersFilter()) + env.register(SmdpExceptionMapper()) + + // Like this one... + env.register(DynamicES2ValidatorAdder()) + } + } + + @Throws(IOException::class) + override fun filter(ctx: ContainerRequestContext) { + + + if (!ctx.uriInfo.path.startsWith(ES2PLUS_PATH_PREFIX)) { + return + } + + val adminProtocol = ctx.headers.getFirst("X-Admin-Protocol") + val userAgent = ctx.headers.getFirst("User-Agent") + + if ("gsma-rsp-lpad" != userAgent) { + ctx.abortWith(Response.status(Response.Status.BAD_REQUEST) + .entity("Illegal user agent, expected gsma-rsp-lpad") + .build()) + } else if (adminProtocol == null || !adminProtocol.startsWith("gsma/rsp/")) { + ctx.abortWith(Response.status(Response.Status.BAD_REQUEST) + .entity("Illegal X-Admin-Protocol header, expected something starting with \"gsma/rsp/\"") + .build()) + } + } +} + +@Provider +class ES2PlusOutgoingHeadersFilter : ContainerResponseFilter { + + @Throws(IOException::class) + override fun filter(requestContext: ContainerRequestContext, + responseContext: ContainerResponseContext) { + responseContext.headers.add("X-Admin-Protocol", X_ADMIN_PROTOCOL_HEADER_VALUE) + } +} + +/** + * Invoked when an exception is thrown when handling an ES2+ request. + * The return value will be a perfectly normal "200" message, since that + * is what the SM-DP+ standard requires. This means we must ourselves + * take the responsibility to log the situation as an error, otherwise it + * will be very difficult to find it in the server logs. + */ +class SmdpExceptionMapper : ExceptionMapper { + + val logger = LoggerFactory.getLogger(SmdpExceptionMapper::class.java) + + override fun toResponse(ex: SmDpPlusException): Response { + + // First we log the event. + logger.error("SM-DP+ processing failed: {}" , ex.statusCodeData) + + // Then we prepare a response that will be returned to + // whoever invoked the resource. + val entity = HeaderOnlyResponse( + header = newErrorHeader(ex)) + + return Response.status(Response.Status.OK) + .entity(entity) + .type(MediaType.APPLICATION_JSON).build() + } +} + +/// +/// The web resource using the protocol domain model. +/// + +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Path(ES2PLUS_PATH_PREFIX) +class SmDpPlusServerResource(private val smDpPlus: SmDpPlusService) { + + companion object { + const val ES2PLUS_PATH_PREFIX : String = "/gsma/rsp2/es2plus/" + } + + /** + * Provided by SM-DP+, called by operator's BSS system. + */ + @Path("downloadOrder") + @POST + fun downloadOrder(order: Es2PlusDownloadOrder): Es2DownloadOrderResponse { + return smDpPlus.downloadOrder( + eid = order.eid, + iccid = order.iccid, + profileType = order.profileType + ) + } + + /** + * Provided by SM-DP+, called by operator's BSS system. + */ + @Path("confirmOrder") + @POST + fun confirmOrder(order: Es2ConfirmOrder): Es2ConfirmOrderResponse { + return smDpPlus.confirmOrder( + eid=order.eid, + iccid = order.iccid, + confirmationCode = order.confirmationCode, + smdsAddress = order.smdpAddress, + machingId = order.matchingId, + releaseFlag = order.releaseFlag + ) + } + + /** + * Provided by SM-DP+, called by operator's BSS system. + */ + @Path("cancelOrder") + @POST + fun cancelOrder(order: Es2CancelOrder): HeaderOnlyResponse { + + smDpPlus.cancelOrder( + eid = order.eid, + iccid = order.iccid, + matchingId = order.matchingId, + finalProfileStatusIndicator = order.finalProfileStatusIndicator) + return HeaderOnlyResponse() + } + + /** + * Provided by SM-DP+, called by operator's BSS system. + */ + @Path("releaseProfile") + @POST + fun releaseProfile(order: Es2ReleaseProfile): HeaderOnlyResponse { + + smDpPlus.releaseProfile(iccid = order.iccid) + return HeaderOnlyResponse() + } +} + + +@Path("/gsma/rsp2/es2plus/") +class SmDpPlusCallbackResource(private val smDpPlus: SmDpPlusCallbackService) { + + /** + * This method is intended to be called _by_ the SM-DP+, sending information + * back to the operator's BSS system about the progress of various + * operations. + */ + @Consumes(MediaType.APPLICATION_JSON) + @Path("handleDownloadProgressInfo") + @POST + fun handleDownloadProgressInfo(order: Es2HandleDownloadProgressInfo): Response { + smDpPlus.handleDownloadProgressInfo( + header = order.header, + eid = order.eid, + iccid = order.iccid, + profileType = order.profileType, + timestamp = order.timestamp, + notificationPointId = order.notificationPointId, + notificationPointStatus = order.notificationPointStatus, + resultData = order.resultData, + imei = order.imei + ) + /* According to the SM-DP+ spec. the response should 204. */ + return Response.status(Response.Status.NO_CONTENT) + .build() + } +} \ No newline at end of file diff --git a/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusServiceInterfaces.kt b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusServiceInterfaces.kt new file mode 100644 index 000000000..113b5da06 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/java/org/ostelco/sim/es2plus/Es2PlusServiceInterfaces.kt @@ -0,0 +1,37 @@ +package org.ostelco.sim.es2plus + + + +class SmDpPlusException(val statusCodeData: StatusCodeData) : Exception() + + +interface SmDpPlusService { + + @Throws(SmDpPlusException::class) + fun downloadOrder(eid: String?, iccid: String?, profileType: String?): Es2DownloadOrderResponse + + @Throws(SmDpPlusException::class) + fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag:Boolean): Es2ConfirmOrderResponse + + @Throws(SmDpPlusException::class) + fun cancelOrder(eid: String?, iccid: String?, matchingId: String?, finalProfileStatusIndicator: String?) + + @Throws(SmDpPlusException::class) + fun releaseProfile(iccid: String) +} + +interface SmDpPlusCallbackService { + + @Throws(SmDpPlusException::class) + fun handleDownloadProgressInfo( + header: ES2RequestHeader, + eid: String?, + iccid: String, + profileType: String, + timestamp: String, + notificationPointId: Int, + notificationPointStatus: ES2NotificationPointStatus, + resultData: String?, + imei: String? + ) +} diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+CancelOrder-def.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+CancelOrder-def.json new file mode 100644 index 000000000..a7d483e76 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+CancelOrder-def.json @@ -0,0 +1,43 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionRequesterIdentifier": { + "type": "string", + "description": "identification of the function requester" + }, + "functionCallIdentifier": { + "type": "string", + "description": "identification of the function call" + } + }, + "required": [ + "functionRequesterIdentifier", + "functionCallIdentifier" + ] + }, + "iccid": { + "type": "string", + "pattern": "^[0-9]{19,20}$", + "description": "ICCID as desc in ITU-T E.118" + }, + "eid": { + "type": "string", + "pattern": "^[0-9]{32}$", + "description": "EID as desc in SGP.02" + }, + "matchingId": { + "type": "string", + "description": "as defined in section {5.3.2}" + }, + "finalProfileStatusIndicator": { + "type": "string", + "description": "as defined in section {5.3.4}" + } + }, + "required": [ + "iccid" + ] +} diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ConfirmOrder-def.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ConfirmOrder-def.json new file mode 100644 index 000000000..7ccae8eb4 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ConfirmOrder-def.json @@ -0,0 +1,54 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionRequesterIdentifier": { + "type": "string", + "description": "identification of the function requester" + }, + "functionCallIdentifier": { + "type": "string", + "description": "identification of the function call" + } + }, + "required": [ + "functionRequesterIdentifier", + "functionCallIdentifier" + ] + }, + "iccid": { + "type": "string", + "pattern": "^[0-9]{19,20}$", + "description": "ICCID as desc in ITU-T E.118" + }, + "eid": { + "type": "string", + "pattern": "^[0-9]{32}$", + "description": "EID as desc in SGP.02" + }, + "matchingId": { + "type": "string", + "pattern": "^[-0-9A-Z]*$", + "description": "as defined in section {5.3.2}" + }, + "confirmationCode": { + "type": "string", + "description": "as defined in section {5.3.2}" + }, + "smdsAddress": { + "type": "string", + "description": "as defined in section {5.3.2}" + }, + "releaseFlag": { + "type": "boolean", + "description": "as defined in section {5.3.2}" + } + }, + "required": [ + "iccid", + "releaseFlag" + ] +} + diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ConfirmOrder-response.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ConfirmOrder-response.json new file mode 100644 index 000000000..9196cdcc6 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ConfirmOrder-response.json @@ -0,0 +1,66 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionExecutionStatus": { + "type": "object", + "description": "Whether the function has been processed correctly or not", + "properties": { + "status": { + "type": "string", + "description": " Executed-Success, Executed-WithWarning, Failed, Expired" + }, + "statusCodeData": { + "type": "object", + "properties": { + "subjectCode": { + "type": "string", + "description": "OID of the subject code" + }, + "reasonCode": { + "type": "string", + "description": "OID of the reason code" + }, + "subjectIdentifier": { + "type": "string", + "description": "Identifier of the subject " + }, + "message": { + "type": "string", + "description": "Textual and human readable explanation" + } + }, + "required": [ + "subjectCode", + "reasonCode" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "functionExecutionStatus" + ] + }, + "eid": { + "type": "string", + "pattern": "^[0-9]{32}$", + "description": "EID as desc in SGP.02" + }, + "matchingId": { + "type": "string", + "description": "as defined in section {5.3.2}" + }, + "smdpAddress": { + "type": "string", + "description": "as defined in section {5.3.2}" + } + }, + "required": [] +} + diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+DownloadOrder-def.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+DownloadOrder-def.json new file mode 100644 index 000000000..839caf04b --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+DownloadOrder-def.json @@ -0,0 +1,37 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionRequesterIdentifier": { + "type": "string", + "description": "identification of the function requester" + }, + "functionCallIdentifier": { + "type": "string", + "description": "identification of the function call" + } + }, + "required": [ + "functionRequesterIdentifier", + "functionCallIdentifier" + ] + }, + "eid": { + "type": "string", + "pattern": "^[0-9]{32}$", + "description": "EID as desc in SGP.02" + }, + "iccid": { + "type": "string", + "pattern": "^[0-9]{19,20}$", + "description": "ICCID as desc in ITU-T E.118" + }, + "profileType": { + "type": "string", + "description": "content free information defined by the Operator" + } + } +} + diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+DownloadOrder-response.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+DownloadOrder-response.json new file mode 100644 index 000000000..0ffc29701 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+DownloadOrder-response.json @@ -0,0 +1,60 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionExecutionStatus": { + "type": "object", + "description": "Whether the function has been processed correctly or not", + "properties": { + "status": { + "type": "string", + "description": " Executed-Success, Executed-WithWarning, Failed, Expired" + }, + "statusCodeData": { + "type": "object", + "properties": { + "subjectCode": { + "type": "string", + "description": "OID of the subject code" + }, + "reasonCode": { + "type": "string", + "description": "OID of the reason code" + }, + "subjectIdentifier": { + "type": "string", + "description": "Identifier of the subject " + }, + "message": { + "type": "string", + "description": "Textual and human readable explanation" + } + }, + "required": [ + "subjectCode", + "reasonCode" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "functionExecutionStatus" + ] + }, + "iccid": { + "type": "string", + "pattern": "^[0-9]{19,20}$", + "description": "ICCID as desc in ITU-T E.118" + } + }, + "required": [ + "iccid" + ] +} + diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+HandleDownloadProgressInfo-def.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+HandleDownloadProgressInfo-def.json new file mode 100644 index 000000000..74a2bd9a8 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+HandleDownloadProgressInfo-def.json @@ -0,0 +1,101 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionRequesterIdentifier": { + "type": "string", + "description": "identification of the function requester" + }, + "functionCallIdentifier": { + "type": "string", + "description": "identification of the function call" + } + }, + "required": [ + "functionRequesterIdentifier", + "functionCallIdentifier" + ] + }, + "eid": { + "type": "string", + "pattern": "^[0-9]{32}$", + "description": "EID as described in SGP.02" + }, + "tac": { + "type": "string", + "pattern": "^[0-9]*$", + "description": "Unknown ID sent by some SM-DP+ vendors" + }, + "iccid": { + "type": "string", + "pattern": "^[0-9]{19,20}F?$", + "description": "ICCID as described in ITU-T E.118" + }, + "profileType": { + "type": "string", + "description": "Content free information defined by the Operator (e.g.‘P9054-2’)" + }, + "timestamp": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[T,D,Z]{1}$", + "description": "String format as specified by W3C: YYYY-MM-DDThh:mm:ssTZD (E.g.2001-12-17T09:30:47Z)" + }, + "notificationPointId": { + "type": "integer", + "description": "Identification of the step reached in the procedure" + }, + "notificationPointStatus": { + "type": "object", + "description": "ExecutionStatus Common Data Type", + "properties": { + "status": { + "type": "string", + "description": "Executed-Success, Executed-WithWarning, Failed or Expired" + }, + "statusCodeData": { + "type": "object", + "properties": { + "subjectCode": { + "type": "string", + "description": "OID of the subject code" + }, + "reasonCode": { + "type": "string", + "description": "OID of the reason code" + }, + "subjectIdentifier": { + "type": "string", + "description": "Identifier of the subject" + }, + "message": { + "type": "string", + "description": "Textual and human readable explanation" + } + }, + "required": [ + "subjectCode", + "reasonCode" + ] + } + }, + "required": [ + "status" + ] + }, + "resultData": { + "type": "string", + "format": "base64", + "description": "base64 encoded binary data containing the Result data contained in the ProfileInstallationResult" + } + }, + "required": [ + "iccid", + "profileType", + "timestamp", + "notificationPointId", + "notificationPointStatus" + ] +} + diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+HeaderOnly-response.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+HeaderOnly-response.json new file mode 100644 index 000000000..0b21020e9 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+HeaderOnly-response.json @@ -0,0 +1,51 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionExecutionStatus": { + "type": "object", + "description": "Whether the function has been processed correctly or not", + "properties": { + "status": { + "type": "string", + "description": " Executed-Success, Executed-WithWarning, Failed, Expired" + }, + "statusCodeData": { + "type": "object", + "properties": { + "subjectCode": { + "type": "string", + "description": "OID of the subject code" + }, + "reasonCode": { + "type": "string", + "description": "OID of the reason code" + }, + "subjectIdentifier": { + "type": "string", + "description": "Identifier of the subject " + }, + "message": { + "type": "string", + "description": "Textual and human readable explanation" + } + }, + "required": [ + "subjectCode", + "reasonCode" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "functionExecutionStatus" + ] + } + } +} diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+JsonResponseHeader-def.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+JsonResponseHeader-def.json new file mode 100644 index 000000000..61a204a7f --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+JsonResponseHeader-def.json @@ -0,0 +1,48 @@ +{ + "header": { + "type": "object", + "properties": { + "functionExecutionStatus": { + "type": "object", + "description": "Whether the function has been processed correctly or not" + "properties": { + "status": { + "type": "string", + "description": " Executed-Success, Executed-WithWarning, Failed, Expired" + }, + "statusCodeData": { + "type": "object", + "properties": { + "subjectCode": { + "type": "string", + "description": "OID of the subject code" + }, + "reasonCode": { + "type": "string", + "description": "OID of the reason code" + }, + "subjectIdentifier": { + "type": "string", + "description": "Identifier of the subject " + }, + "message": { + "type": "string", + "description": "Textual and human readable explanation" + } + }, + "required": [ + "subjectCode", + "reasonCode" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "functionExecutionStatus" + ] + } +} diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ReleaseProfile-def.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ReleaseProfile-def.json new file mode 100644 index 000000000..8b05311de --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/ES2+ReleaseProfile-def.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionRequesterIdentifier": { + "type": "string", + "description": "identification of the function requester" + }, + "functionCallIdentifier": { + "type": "string", + "description": "identification of the function call" + } + }, + "required": [ + "functionRequesterIdentifier", + "functionCallIdentifier" + ] + }, + "iccid": { + "type": "string", + "pattern": "^[0-9]{19,20}$", + "description": "ICCID as desc in ITU-T E.118" + } + }, + "required": [ + "iccid" + ] +} diff --git a/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/JSONrequestHeader-def.json b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/JSONrequestHeader-def.json new file mode 100644 index 000000000..17262c9bf --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/main/resources/es2schemas/JSONrequestHeader-def.json @@ -0,0 +1,19 @@ +{ + "header": { + "type": "object", + "properties": { + "functionRequesterIdentifier": { + "type": "string", + "description": "identification of the function requester" + }, + "functionCallIdentifier": { + "type": "string", + "description": "identification of the function call" + } + }, + "required": [ + "functionRequesterIdentifier", + "functionCallIdentifier" + ] + } +} diff --git a/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/EncryptedRemoteEs2ClientOnlyTest.kt b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/EncryptedRemoteEs2ClientOnlyTest.kt new file mode 100644 index 000000000..4498defc0 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/EncryptedRemoteEs2ClientOnlyTest.kt @@ -0,0 +1,179 @@ +package org.ostelco.sim.es2plus + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import io.dropwizard.testing.DropwizardTestSupport +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertNotNull +import junit.framework.Assert.assertTrue +import org.apache.http.client.HttpClient +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import javax.validation.Valid +import javax.validation.constraints.NotNull + +class EncryptedRemoteEs2ClientOnlyTest { + + // This ICCID should be reserved for testing only, and should never be used + // for any other purpose. + private val iccid = "8947000000000000038" + + + private lateinit var client: ES2PlusClient + + @Before + fun setUp() { + SUPPORT.before() + this.client = SUPPORT.getApplication().es2plusClient + } + + @After + fun tearDown() { + SUPPORT.after() + } + + + private fun getState(): String { + val profileStatus = + client.profileStatus(iccidList = listOf(iccid)) + assertEquals(FunctionExecutionStatusType.ExecutedSuccess, profileStatus.header.functionExecutionStatus.status) + assertEquals(1, profileStatus.profileStatusList!!.size) + + val profileStatusResponse = profileStatus.profileStatusList!![0] + + assertTrue(profileStatusResponse.iccid!!.startsWith(iccid)) + assertNotNull(profileStatusResponse.state) + return profileStatusResponse.state!! + } + + + /** + * Run the typical scenario we run when allocating a sim profile. + * The only exception is the optional move to "available" if not already + * in that state. + */ + @Test + @Ignore + fun handleHappyDayScenarioTowardsRemote() { + + if ("AVAILABLE" != getState()) { + setStateToAvailable() + } + downloadProfile() + confirmOrder() + } + + private fun confirmOrder() { + val confirmResponse = + client.confirmOrder( + iccid = iccid, + releaseFlag = true) + + // This happens to be the matching ID used for everything in the test application, not a good + // assumption for production code, but this isn't that. + assertEquals(FunctionExecutionStatusType.ExecutedSuccess, confirmResponse.header.functionExecutionStatus.status) + + assertEquals("RELEASED", getState()) + } + + private fun setStateToAvailable() { + val cancelOrderResult = + client.cancelOrder( + iccid = iccid, + finalProfileStatusIndicator = "AVAILABLE" + ) + assertEquals(FunctionExecutionStatusType.ExecutedSuccess, cancelOrderResult.header.functionExecutionStatus.status) + + + assertEquals("AVAILABLE", getState()) + } + + private fun downloadProfile() { + val downloadResponse = client.downloadOrder(iccid = iccid) + + assertEquals(FunctionExecutionStatusType.ExecutedSuccess, downloadResponse.header.functionExecutionStatus.status) + assertTrue(downloadResponse.iccid!!.startsWith(iccid)) + + assertEquals("ALLOCATED", getState()) + } + + companion object { + val SUPPORT = DropwizardTestSupport( + DummyAppUsingSmDpPlusClient::class.java, + "src/test/resources/config-external-smdp.yml" + ) + } +} + + + +class DummyAppUsingSmDpPlusClient : Application() { + + override fun getName(): String { + return "Dummy, just for initialization and setting up a client" + } + + override fun initialize(bootstrap: Bootstrap) { + // TODO: application initialization + } + + private lateinit var httpClient: HttpClient + + lateinit var es2plusClient: ES2PlusClient + + override fun run(config: DummyAppUsingSmDpPlusClientConfig, + env: Environment) { + + this.httpClient = HttpClientBuilder(env).using(config.httpClientConfiguration).build(name) + this.es2plusClient = ES2PlusClient( + requesterId = config.es2plusConfig.requesterId, + host = config.es2plusConfig.host, + port = config.es2plusConfig.port, + httpClient = httpClient) + } + + + companion object { + + @Throws(Exception::class) + @JvmStatic + fun main(args: Array) { + DummyAppUsingSmDpPlusClient().run(*args) + } + } +} + + + +/** + * Configuration class for SM-DP+ emulator. + */ +class DummyAppUsingSmDpPlusClientConfig : Configuration() { + + /** + * Configuring how the Open API representation of the + * served resources will be presenting itself (owner, + * license etc.) + */ + @Valid + @NotNull + @JsonProperty("es2plusClient") + var es2plusConfig = EsTwoPlusConfig() + + + /** + * The httpClient we use to connect to other services, including + * ES2+ services + */ + @Valid + @NotNull + @JsonProperty("httpClient") + var httpClientConfiguration = HttpClientConfiguration() +} \ No newline at end of file diff --git a/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2PlusResourceTest.kt b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2PlusResourceTest.kt new file mode 100644 index 000000000..bf3dbff91 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2PlusResourceTest.kt @@ -0,0 +1,161 @@ +package org.ostelco.sim.es2plus + +import io.dropwizard.testing.junit.ResourceTestRule +import org.junit.AfterClass +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.reset +import org.ostelco.jsonschema.DynamicES2ValidatorAdder + + +class ES2PlusResourceTest { + + + private val iccid = "01234567890123456789" + private val eid = "01234567890123456789012345678901" + private val matchingId = "ABCD-EFGH-IJKL-MNOP-1234" + private val confirmationCode = "bar" + + + companion object { + + val smdpPlusService: SmDpPlusService = Mockito.mock(SmDpPlusService::class.java) + val callbackService: SmDpPlusCallbackService = Mockito.mock(SmDpPlusCallbackService::class.java) + + @JvmField + @ClassRule + val RULE: ResourceTestRule = ResourceTestRule + .builder() + .addResource(SmDpPlusServerResource(smdpPlusService)) + .addResource(SmDpPlusCallbackResource(callbackService)) + .addProvider(ES2PlusIncomingHeadersFilter()) + .addProvider(DynamicES2ValidatorAdder()) + .addProvider(ES2PlusOutgoingHeadersFilter()) + .build() + + @JvmStatic + @AfterClass + fun afterClass() { + } + } + + @Before + fun setUp() { + reset(smdpPlusService) + reset(callbackService) + } + + private val client = ES2PlusClient( + requesterId = "Integration test client", + jerseyClient = RULE.client()) + + @Test + fun testDownloadOrder() { + + Mockito.`when`(smdpPlusService.downloadOrder( + eid = Mockito.anyString(), + iccid = Mockito.anyString(), + profileType = Mockito.anyString())) + .thenReturn(Es2DownloadOrderResponse( + header = eS2SuccessResponseHeader(), + iccid = iccid + )) + + val result = client.downloadOrder( + eid = eid, + iccid = iccid, + profileType = "AProfileTypeOfSomeSort") + } + + + @Test + fun testConfirmOrder() { + + Mockito.`when`(smdpPlusService.confirmOrder( + eid = Mockito.anyString(), + iccid = Mockito.anyString(), + smdsAddress = Mockito.anyString(), + machingId = Mockito.anyString(), + confirmationCode = Mockito.anyString(), + releaseFlag = Mockito.anyBoolean())) + .thenReturn( + Es2ConfirmOrderResponse( + header = eS2SuccessResponseHeader(), + eid = "12345678901234567890123456789012", + matchingId = "BANANAS-ARE-GREAT")) + + + client.confirmOrder( + eid = eid, + iccid = iccid, + matchingId = matchingId, + confirmationCode = confirmationCode, + smdpAddress = "baz", + releaseFlag = true) + } + + @Test + fun testCancelOrder() { + client.cancelOrder( + eid = eid, + iccid = iccid, + matchingId = matchingId, + finalProfileStatusIndicator = confirmationCode) + // XXX Do some verification + } + + @Test + fun testReleaseProfile() { + client.releaseProfile(iccid = iccid) + // XXX Do some verification + } + + @Test + fun testHandleDownloadProgressInfo() { + // XXX Not testing anything sensible + client.handleDownloadProgressInfo( + iccid = iccid, + eid = eid, + profileType = "profileType", + timestamp = "2001-12-17T09:30:47Z", + notificationPointId = 4711, + notificationPointStatus = ES2NotificationPointStatus() + ) + // XXX Do some verification + } + + + @Test + fun testHandleDownloadProgressInfoForIccidWithSuffixF() { + // XXX Not testing anything sensible + client.handleDownloadProgressInfo( + iccid = iccid + "F", + eid = eid, + profileType = "profileType", + timestamp = "2001-12-17T09:30:47Z", + notificationPointId = 4711, + notificationPointStatus = ES2NotificationPointStatus() + ) + // XXX Do some verification + } + + + @Test(expected = ES2PlusClientException::class) + fun testHandleDownloadProgressInfoForIccidWithSuffixZ() { + // XXX Not testing anything sensible + client.handleDownloadProgressInfo( + iccid = iccid + "Z", + eid = eid, + profileType = "profileType", + timestamp = "2001-12-17T09:30:47Z", + notificationPointId = 4711, + notificationPointStatus = ES2NotificationPointStatus() + ) + // XXX Do some verification + } + + // XXX Not testing error cases, to ensure that the exception, error reporting + // mechanism is working properly. +} diff --git a/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2plusApplication.kt b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2plusApplication.kt new file mode 100644 index 000000000..859c0df9b --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2plusApplication.kt @@ -0,0 +1,97 @@ +package org.ostelco.sim.es2plus + +import io.dropwizard.Application +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource +import io.swagger.v3.oas.integration.SwaggerConfiguration +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import org.ostelco.sim.es2plus.ES2PlusIncomingHeadersFilter.Companion.addEs2PlusDefaultFiltersAndInterceptors +import java.util.stream.Collectors +import java.util.stream.Stream + +class Es2plusApplication : Application() { + + override fun getName(): String { + return "es2+ application" + } + + override fun initialize(bootstrap: Bootstrap) { + // TODO: application initialization + } + + override fun run(configuration: Es2plusConfiguration, + environment: Environment) { + + // XXX Add these parameters to configuration file. + val oas = OpenAPI() + val info = Info() + .title(name) + .description("Restful membership management.") + .termsOfService("http://example.com/terms") + .contact(Contact().email("la3lma@gmail.com")) + + oas.info(info) + val oasConfig = SwaggerConfiguration() + .openAPI(oas) + .prettyPrint(true) + .resourcePackages(Stream.of("no .rmz.membershipmgt") + .collect(Collectors.toSet())) + val env = environment.jersey() + env.register(OpenApiResource() + .openApiConfiguration(oasConfig)) + + + addEs2PlusDefaultFiltersAndInterceptors(env) + } + + companion object { + @Throws(Exception::class) + @JvmStatic + fun main(args: Array) { + Es2plusApplication().run(*args) + } + } + + // We're basing this implementation on + // https://www.gsma.com/newsroom/wp-content/uploads/SGP.22-v2.0.pdf +} + + +class PlaceholderSmDpPlusService : SmDpPlusService { + @Throws(SmDpPlusException::class) + override fun downloadOrder(eid: String?, iccid: String?, profileType: String?): Es2DownloadOrderResponse { + return Es2DownloadOrderResponse(eS2SuccessResponseHeader(), iccid="01234567890123456789") + } + + override fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag: Boolean): Es2ConfirmOrderResponse { + return Es2ConfirmOrderResponse(eS2SuccessResponseHeader(), eid="1234567890123456789012", matchingId = "foo", smdsAddress = "localhost") + } + + @Throws(SmDpPlusException::class) + override fun cancelOrder(iccid: String?, matchingId: String?, eid: String?, finalProfileStatusIndicator: String?) { + } + + @Throws(SmDpPlusException::class) + override fun releaseProfile(iccid: String) { + } +} + + +class PlaceholderSmDpPlusCallbackService : SmDpPlusCallbackService { + override fun handleDownloadProgressInfo( + header: ES2RequestHeader, + eid: String?, + iccid: String, + profileType: String, + timestamp: String, + notificationPointId: Int, + notificationPointStatus: ES2NotificationPointStatus, + resultData: String?, + imei: String?) { + + } +} + diff --git a/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2plusConfiguration.kt b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2plusConfiguration.kt new file mode 100644 index 000000000..6e8972370 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/test/java/org/ostelco/sim/es2plus/Es2plusConfiguration.kt @@ -0,0 +1,6 @@ +package org.ostelco.sim.es2plus + +import io.dropwizard.Configuration + + +class Es2plusConfiguration : Configuration()// TODO: implement service configuration diff --git a/sim-administration/es2plus4dropwizard/src/test/resources/config-external-smdp.yml b/sim-administration/es2plus4dropwizard/src/test/resources/config-external-smdp.yml new file mode 100644 index 000000000..a19113c72 --- /dev/null +++ b/sim-administration/es2plus4dropwizard/src/test/resources/config-external-smdp.yml @@ -0,0 +1,13 @@ +es2plusClient: + requesterId: xx + host: localhost + port: 8080 + +httpClient: + timeout: 500ms + connectionTimeout: 500ms + timeToLive: 1 hour + cookiesEnabled: false + maxConnections: 1024 + maxConnectionsPerRoute: 1024 + keepAlive: 0s diff --git a/sim-administration/hss-adapter/build.gradle b/sim-administration/hss-adapter/build.gradle new file mode 100644 index 000000000..96554a710 --- /dev/null +++ b/sim-administration/hss-adapter/build.gradle @@ -0,0 +1,118 @@ +plugins { + id "application" + id "com.google.protobuf" version "0.8.8" + id "org.jetbrains.kotlin.jvm" + id "idea" + id "com.github.johnrengelman.shadow" version "5.0.0" +} + +dependencies { + + implementation project(":prime-modules") + + implementation project(":sim-administration:jersey-json-schema-validator") + implementation project(":sim-administration:simcard-utils") + implementation project(":sim-administration:simmanager") + implementation project(":sim-administration:ostelco-dropwizard-utils") + + api "io.grpc:grpc-netty-shaded:$grpcVersion" + api "io.grpc:grpc-protobuf:$grpcVersion" + api "io.grpc:grpc-stub:$grpcVersion" + api "io.grpc:grpc-core:$grpcVersion" + implementation 'javax.annotation:javax.annotation-api:1.3.2' + + api "io.arrow-kt:arrow-core:$arrowVersion" + api "io.arrow-kt:arrow-typeclasses:$arrowVersion" + api "io.arrow-kt:arrow-instances-core:$arrowVersion" + api "io.arrow-kt:arrow-effects:$arrowVersion" + + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-jdbi3:$dropwizardVersion" + implementation "io.dropwizard.metrics:metrics-core:$metricsVersion" + implementation "com.google.guava:guava:$guavaVersion" + + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0" + + + // XXX IF this works, then it's too general. We're not using the + // postgres, but we are use some of the dependencies. + testImplementation "org.testcontainers:postgresql:1.10.5" +} + +configurations { + integration + integrationImplementation.extendsFrom implementation + integrationImplementation.extendsFrom runtime + integrationImplementation.extendsFrom runtimeOnly + integrationImplementation.extendsFrom testImplementation +} + +shadowJar { + mainClassName = 'org.ostelco.simcards.hss.HssAdapterApplicationKt' + mergeServiceFiles() + classifier = "uber" + zip64 true + version = null +} + +protobuf { + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + + protoc { artifact = "com.google.protobuf:protoc:$protocVersion" } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} + + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +sourceSets { + integration { + kotlin { + compileClasspath += main.output + test.output + runtimeClasspath += main.output + test.output + srcDirs += file('src/integration-test/kotlin') + } + resources.srcDir file('src/integration-test/resources') + } +} + + + +apply from: '../../gradle/jacoco.gradle' + +idea { + module { + sourceDirs += files("${protobuf.generatedFilesBaseDir}/main/java") + sourceDirs += files("${protobuf.generatedFilesBaseDir}/main/grpc") + testSourceDirs += file('src/integration-test/kotlin') + } +} + + + +task integration(type: Test, description: 'Runs the integration tests.', group: 'Verification') { + testClassesDirs = sourceSets.integration.output.classesDirs + classpath = sourceSets.integration.runtimeClasspath +} + +build.dependsOn integration + diff --git a/sim-administration/hss-adapter/src/integration-test/kotlin/org/ostelco/simcards/hss/HssAdapterIntegrationTest.kt b/sim-administration/hss-adapter/src/integration-test/kotlin/org/ostelco/simcards/hss/HssAdapterIntegrationTest.kt new file mode 100644 index 000000000..e5bb6ec7b --- /dev/null +++ b/sim-administration/hss-adapter/src/integration-test/kotlin/org/ostelco/simcards/hss/HssAdapterIntegrationTest.kt @@ -0,0 +1,88 @@ +package org.ostelco.simcards.hss + +import arrow.core.Either +import io.dropwizard.testing.ResourceHelpers +import io.dropwizard.testing.junit.DropwizardAppRule +import junit.framework.Assert.* +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.ostelco.prime.simmanager.SimManagerError +import org.testcontainers.containers.FixedHostPortGenericContainer + +/** + * This test shall set up a docker test environment running a simulated HSS (of the simple type), and + * a test-instance of the HssGrpcService application running under junit. We shall then + * use the GRPC client to connect to the HssGrpcServce and observe that it correctly + * translates the grpc requests into valid requests that are sent to the simulated HSS server. + **/ +class HssAdapterIntegrationTest { + + class KFixedHostPortGenericContainer(imageName: String) : + FixedHostPortGenericContainer(imageName) + + companion object { + @JvmField + @ClassRule + val MOCK_HSS_RULE = DropwizardAppRule(MockHssServer::class.java, + ResourceHelpers.resourceFilePath("mock-hss-server-config.yaml")) + + + @JvmField + @ClassRule + val HSS_ADAPTER_RULE = DropwizardAppRule(HssAdapterApplication::class.java, + ResourceHelpers.resourceFilePath("hss-adapter-config.yaml") + ) + } + + var HSS_NAME = "Foo" + var MSISDN = "4712345678" + var ICCID = "89310410106543789301" + + lateinit var hssAdapter: HssAdapterApplication + lateinit var hssApplication: MockHssServer + + lateinit var adapter: HssDispatcher + + @Before + fun setUp() { + hssAdapter = HSS_ADAPTER_RULE.getApplication() + hssApplication = MOCK_HSS_RULE.getApplication() + hssApplication.reset() + adapter = HssGrpcAdapter("127.0.0.1", 9000) + } + + @Test + fun testActivationWithoutUsingGrpc() { + assertFalse(hssApplication.isActivated(ICCID)) + val result : Either = + hssAdapter.dispatcher.activate(hssName = HSS_NAME, iccid = ICCID, msisdn = MSISDN) + assertTrue(result.isRight()) + assertTrue(hssApplication.isActivated(ICCID)) + } + + @Test + fun testActivationUsingGrpc() { + + val response = adapter.activate(hssName = HSS_NAME, iccid = ICCID, msisdn = MSISDN) + response.mapLeft { msg -> fail("Failed to activate via grpc: $msg") } + assertTrue(hssApplication.isActivated(ICCID)) + } + + @Test + fun testSuspensionUsingGrpc() { + adapter.activate(hssName = HSS_NAME, iccid = ICCID, msisdn = MSISDN) + assertTrue(hssApplication.isActivated(ICCID)) + val response = adapter.suspend(hssName = HSS_NAME, iccid = ICCID) + response.mapLeft { msg -> fail("Failed to suspend via grpc: ${msg.description}") } + assertTrue(!hssApplication.isActivated(ICCID)) + } + + @Test + fun testPositiveHealthcheck() { + assertFalse(adapter.iAmHealthy()) + HSS_ADAPTER_RULE.environment.healthChecks().runHealthChecks() + assertTrue(adapter.iAmHealthy()) + } +} + diff --git a/sim-administration/hss-adapter/src/integration-test/kotlin/org/ostelco/simcards/hss/MockHssServer.kt b/sim-administration/hss-adapter/src/integration-test/kotlin/org/ostelco/simcards/hss/MockHssServer.kt new file mode 100644 index 000000000..0724a3c12 --- /dev/null +++ b/sim-administration/hss-adapter/src/integration-test/kotlin/org/ostelco/simcards/hss/MockHssServer.kt @@ -0,0 +1,99 @@ +package org.ostelco.simcards.hss + +import com.codahale.metrics.annotation.Timed +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import javax.ws.rs.* +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + + +/** + * An simple HTTP-serving mock for serving HSS requests. Intended only for + * test use. + */ +class MockHssServer : Application() { + + + override fun getName(): String { + return "Mock Hss Server" + } + + override fun initialize(bootstrap: Bootstrap?) { + // nothing to do yet + } + + private lateinit var resource: MockHssResource + + override fun run(configuration: MockHssServerConfiguration, + env: Environment) { + + this.resource = MockHssResource() + env.jersey().register(resource); + } + + fun reset() { + this.resource.reset() + } + + fun isActivated(iccid: String): Boolean { + return this.resource.isActivated(iccid) + } +} + + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Subscription( + @JsonProperty("bssid") var bssid: String, + @JsonProperty("iccid") var iccid: String, + @JsonProperty("msisdn") var msisdn: String, + @JsonProperty("userid") var userid: String) + + +/** + * A very interface that could be used to connect to an HSS + */ +@Path("/default/provision") +@Produces(MediaType.APPLICATION_JSON) +class MockHssResource() { + + val activated = mutableMapOf() + + @POST + @Timed + @Path("/activate") + fun activate(sub: Subscription) : Response { + + activated[sub.iccid] = sub + + return Response.status(Response.Status.CREATED) + .type(MediaType.APPLICATION_JSON) + .build() + } + + @DELETE + @Timed + @Path("/deactivate/{iccid}") + fun deactivate(@PathParam("iccid") iccid:String ) : Response { + activated.remove(iccid) + return Response.status(Response.Status.OK) + .type(MediaType.APPLICATION_JSON) + .build() + } + + fun reset() { + activated.clear() + } + + fun isActivated(iccid: String): Boolean { + return activated.contains(iccid) + } +} + + +class MockHssServerConfiguration : Configuration() { +} \ No newline at end of file diff --git a/sim-administration/hss-adapter/src/integration-test/resources/hss-adapter-config.yaml b/sim-administration/hss-adapter/src/integration-test/resources/hss-adapter-config.yaml new file mode 100644 index 000000000..0b0547320 --- /dev/null +++ b/sim-administration/hss-adapter/src/integration-test/resources/hss-adapter-config.yaml @@ -0,0 +1,13 @@ +logging: + level: INFO + + +hlrs: + - name: Foo + endpoint: http://localhost:9180/default/provision + userId: user + apiKey: xyz + + +httpClient: + timeout: 10000ms diff --git a/sim-administration/hss-adapter/src/integration-test/resources/mock-hss-server-config.yaml b/sim-administration/hss-adapter/src/integration-test/resources/mock-hss-server-config.yaml new file mode 100644 index 000000000..a743dd6b7 --- /dev/null +++ b/sim-administration/hss-adapter/src/integration-test/resources/mock-hss-server-config.yaml @@ -0,0 +1,8 @@ + +server: + applicationConnectors: + - type: http + port: 9180 + adminConnectors: + - type: http + port: 9181 diff --git a/sim-administration/hss-adapter/src/main/kotlin/org/ostelco/simcards/hss/HssAdapterApplication.kt b/sim-administration/hss-adapter/src/main/kotlin/org/ostelco/simcards/hss/HssAdapterApplication.kt new file mode 100644 index 000000000..7acaa5366 --- /dev/null +++ b/sim-administration/hss-adapter/src/main/kotlin/org/ostelco/simcards/hss/HssAdapterApplication.kt @@ -0,0 +1,86 @@ +package org.ostelco.simcards.hss + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import org.ostelco.simcards.admin.HssConfig +import javax.validation.Valid +import javax.validation.constraints.NotNull + + +/** + * Boilerplate entry point. + */ +fun main(args: Array) = HssAdapterApplication().run(*args) + +/** + * The sim manager will have to interface to many different Home Subscriber Module + * instances. Many of these will rely on proprietary libraries to interface to the + * HSS. We strongly believe that the majority of the Ostelco project's source code + * should be open sourced, but it is impossible to open source something that isn't ours, + * so we can't open source HSS libraries, and we won't. + * + * Instead we'll do the next best thing: We'll make it simple to create adapters + * for these libraries and make them available to the ostelco core. + * + * Our strategy is to make a service, implemented by the HssAdapterApplication, that + * will be available as an external executable, via rest (or possibly gRPC, not decided + * at the time this documentation is being written). The "simmanager" module of the open + * source Prime component will then connect to the hss profilevendors and make requests for + * activation/suspension/deletion. + * + * This component is written in the open source project, and it contains a non-proprietary + * implementation of a simple HSS interface. We provide this as a template so that when + * proprietary code is added to this application, it can be done in the same way as the + * simple non-proprietary implementation was added. You are however expected to do that, + * and make your service, deploy it separately and tell the prime component where it is + * (typically using kubernetes service lookup or something similar). + */ +class HssAdapterApplication : Application() { + + public lateinit var dispatcher: DirectHssDispatcher + + override fun getName(): String { + return "HSS adapter service" + } + + override fun initialize(bootstrap: Bootstrap?) { + // nothing to do yet + } + + override fun run(configuration: HssAdapterApplicationConfiguration, + env: Environment) { + + val httpClient = HttpClientBuilder(env) + .using(configuration.httpClient) + .build("${getName()} http client") + val jerseyEnv = env.jersey() + + val myHssService = ManagedHssGrpcService( + port = 9000, + env = env, + httpClient = httpClient, + configuration = configuration.hssVendors) + + this.dispatcher = myHssService.dispatcher + + env.lifecycle().manage(myHssService) + } +} + + +class HssAdapterApplicationConfiguration : Configuration() { + @Valid + @NotNull + @JsonProperty("hlrs") + lateinit var hssVendors: List + + @Valid + @NotNull + @JsonProperty("httpClient") + val httpClient = HttpClientConfiguration() +} diff --git a/sim-administration/hss-adapter/src/main/kotlin/org/ostelco/simcards/hss/HssGrpcService.kt b/sim-administration/hss-adapter/src/main/kotlin/org/ostelco/simcards/hss/HssGrpcService.kt new file mode 100644 index 000000000..965773f5f --- /dev/null +++ b/sim-administration/hss-adapter/src/main/kotlin/org/ostelco/simcards/hss/HssGrpcService.kt @@ -0,0 +1,107 @@ +package org.ostelco.simcards.hss + +import com.codahale.metrics.health.HealthCheck +import io.dropwizard.lifecycle.Managed +import io.dropwizard.setup.Environment +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.stub.StreamObserver +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.simcards.admin.HssConfig +import org.ostelco.simcards.admin.mapRight +import org.ostelco.simcards.hss.profilevendors.api.* + +class ManagedGrpcService(private val port: Int, + private val service: io.grpc.BindableService) : Managed { + + private var server: Server + + init { + this.server = ServerBuilder.forPort(port) + .addService(service) + .build() + } + + @Throws(Exception::class) + override fun start() { + server.start() + } + + @Throws(Exception::class) + override fun stop() { + server.shutdown() + server.awaitTermination() + } +} + +class ManagedHssGrpcService( + configuration: List, + private val env: Environment, + httpClient: CloseableHttpClient, + port: Int) : Managed { + + private val managedGrpcService: ManagedGrpcService + val dispatcher: DirectHssDispatcher + + init { + val dispatchers = mutableSetOf() + + this.dispatcher = DirectHssDispatcher( + hssConfigs = configuration, + httpClient = httpClient, + healthCheckRegistrar = object : HealthCheckRegistrar { + override fun registerHealthCheck(name: String, healthCheck: HealthCheck) { + env.healthChecks().register(name, healthCheck) + } + }) + + val hssService = HssServiceImpl(dispatcher) + + this.managedGrpcService = ManagedGrpcService(port = port, service = hssService) + } + + @Throws(Exception::class) + override fun start() { + managedGrpcService.start() + } + + @Throws(Exception::class) + override fun stop() { + managedGrpcService.stop() + } +} + +class HssServiceImpl(private val hssDispatcher: HssDispatcher) : HssServiceGrpc.HssServiceImplBase() { + + override fun getHealthStatus(request: ServiceHealthQuery?, responseObserver: StreamObserver?) { + + if (request == null) return + if (responseObserver == null) return + + val payload = hssDispatcher.iAmHealthy() + val response = ServiceHealthStatus.newBuilder().setIsHealthy(payload).build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + + override fun activate(request: ActivationRequest?, responseObserver: StreamObserver?) { + + if (request == null) return + if (responseObserver == null) return + + hssDispatcher.activate(hssName = request.hss, iccid = request.iccid, msisdn = request.msisdn) + .mapRight { responseObserver.onNext(HssServiceResponse.newBuilder().setSuccess(true).build()) } + .mapLeft { responseObserver.onNext(HssServiceResponse.newBuilder().setSuccess(false).build()) } + responseObserver.onCompleted() + } + + override fun suspend(request: SuspensionRequest?, responseObserver: StreamObserver?) { + if (request == null) return + if (responseObserver == null) return + + hssDispatcher.suspend(hssName = request.hss, iccid = request.iccid) + .mapRight { responseObserver.onNext(HssServiceResponse.newBuilder().setSuccess(true).build()) } + .mapLeft { responseObserver.onNext(HssServiceResponse.newBuilder().setSuccess(false).build()) } + responseObserver.onCompleted() + } +} diff --git a/sim-administration/jersey-json-schema-validator/build.gradle b/sim-administration/jersey-json-schema-validator/build.gradle new file mode 100644 index 000000000..2e38b85ad --- /dev/null +++ b/sim-administration/jersey-json-schema-validator/build.gradle @@ -0,0 +1,14 @@ +plugins { + id "org.jetbrains.kotlin.jvm" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "com.github.everit-org.json-schema:org.everit.json.schema:1.11.1" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" +} + +apply from: '../../gradle/jacoco.gradle' \ No newline at end of file diff --git a/sim-administration/jersey-json-schema-validator/src/main/java/org/ostelco/jsonschema/JsonSchemaValidator.kt b/sim-administration/jersey-json-schema-validator/src/main/java/org/ostelco/jsonschema/JsonSchemaValidator.kt new file mode 100644 index 000000000..55e32e285 --- /dev/null +++ b/sim-administration/jersey-json-schema-validator/src/main/java/org/ostelco/jsonschema/JsonSchemaValidator.kt @@ -0,0 +1,162 @@ +package org.ostelco.jsonschema + +import org.everit.json.schema.Schema +import org.everit.json.schema.SchemaException +import org.everit.json.schema.ValidationException +import org.json.JSONException +import org.json.JSONObject +import org.json.JSONTokener +import java.io.* +import java.nio.charset.Charset +import javax.ws.rs.WebApplicationException +import javax.ws.rs.container.DynamicFeature +import javax.ws.rs.container.ResourceInfo +import javax.ws.rs.core.FeatureContext +import javax.ws.rs.core.Response +import javax.ws.rs.ext.* + + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class JsonSchema(val schemaKey: String) + + +class JsonSchemaValidator { + private val schemaRoot = "/es2schemas" + private var schemaMap: MutableMap = mutableMapOf() + + + private fun loadJsonSchemaResource(name: String): Schema { + val inputStream = this.javaClass.getResourceAsStream("$schemaRoot/$name.json") ?: throw WebApplicationException("Unknown schema map: '$name'", Response.Status.INTERNAL_SERVER_ERROR) + try { + val jsonEncodedSchemaDescription = JSONObject(JSONTokener(inputStream)) + return org.everit.json.schema.loader.SchemaLoader.load(jsonEncodedSchemaDescription) + } catch (e: JSONException) { + val msg = e.message + throw WebApplicationException("Syntax error in schema description named '$name'. Error: $msg", Response.Status.INTERNAL_SERVER_ERROR) + } catch (e: SchemaException) { + throw WebApplicationException("Illegal Schema definition for schema: '$name'. Error: ${e.message}") + } + } + + private fun getSchema(name: String): Schema { + val result: Schema + if (!schemaMap.containsKey(name)) { + result = loadJsonSchemaResource(name) + schemaMap[name] = result + } else { + result = schemaMap[name]!! + } + return result + } + + @Throws(WebApplicationException::class) + fun validateString(payloadClass: Class<*>, body: String, error: Response.Status) { + val schemaAnnotation = payloadClass.getAnnotation(JsonSchema::class.java) + if (schemaAnnotation != null) { + try { + getSchema(schemaAnnotation.schemaKey).validate(JSONObject(body)) + } catch (t: ValidationException) { + var causes = t.causingExceptions.joinToString(separator = ". ") { e: ValidationException -> "${e.keyword}: ${e.errorMessage}" } + if (causes.isBlank()) { + causes = t.errorMessage + } + val msg = "Schema validation failed while validating schema named: '${schemaAnnotation.schemaKey}'. Error: $t.message. Causes= $causes" + // XXX The web application exception seems to be swallowed. + throw WebApplicationException(msg, error) + } + } + } +} + +@Provider +class DynamicES2ValidatorAdder : DynamicFeature { + + override fun configure(resourceInfo: ResourceInfo, context: FeatureContext) { + val allAnnotations = mutableSetOf() + + resourceInfo.resourceMethod.returnType.annotations.map { allAnnotations.add(it.annotationClass.java) } + resourceInfo.resourceMethod.parameterTypes.map { it -> it.annotations.map { allAnnotations.add(it.annotationClass.java) }} + + if (allAnnotations.contains(JsonSchema::class.java)) { + context.register(RequestServerReaderWriterInterceptor::class.java) + } + } +} + +@Provider +private class RequestServerReaderWriterInterceptor : ReaderInterceptor, WriterInterceptor { + + private val validator = JsonSchemaValidator() + + @Throws(IOException::class) + private fun toByteArray(input: InputStream): ByteArray { + ByteArrayOutputStream().use { output -> + copy(input, output) + return output.toByteArray() + } + } + + @Throws(IOException::class) + private fun copy(input: InputStream, output: OutputStream): Int { + val count = copyLarge(input, output,ByteArray(512)) + return if (count > Integer.MAX_VALUE) { + -1 + } else count.toInt() + } + + @Throws(IOException::class) + fun copyLarge(input: InputStream, output: OutputStream, buffer: ByteArray): Long { + var count: Long = 0 + var n: Int = input.read(buffer) + while (-1 != n) { + output.write(buffer, 0, n) + count += n.toLong() + n = input.read(buffer) + } + return count + } + + @Throws(IOException::class, WebApplicationException::class) + override fun aroundReadFrom(ctx: ReaderInterceptorContext): Any { + + val originalStream = ctx.inputStream + val originalByteArray = toByteArray(originalStream) + val body = String(originalByteArray, Charset.forName("UTF-8")) + + validator.validateString(ctx.type, body, Response.Status.BAD_REQUEST) + + ctx.inputStream = ByteArrayInputStream(originalByteArray) + return ctx.proceed() + } + + @Throws(IOException::class, WebApplicationException::class) + override fun aroundWriteTo(ctx: WriterInterceptorContext) { + + // Switch out the original output stream with a + // ByteArrayOutputStream that we can get a byte + // array out of + val origin = ctx.outputStream!! + val interceptingStream = ByteArrayOutputStream() + ctx.outputStream = interceptingStream + + // Proceed, meaning that we'll get all the input + // sent into the byte output (intercepting) stream. + ctx.proceed() + + // Then get the byte array & convert it to a nice + // UTF-8 string + val contentBytes = interceptingStream.toByteArray() + val contentString = String(contentBytes, Charset.forName("UTF-8")) + + // Validate our now serialized input. + val type = ctx.entity::class.java + validator.validateString(ctx.type, contentString, Response.Status.INTERNAL_SERVER_ERROR) + + // Now we write the original entity back + // to the "filtered" output stream to be transmitted + // over the wire. + origin.write(contentBytes) + ctx.outputStream = origin + } +} diff --git a/sim-administration/jersey-json-schema-validator/src/test/java/org/ostelco/jsonschema/TestJsonValidation.kt b/sim-administration/jersey-json-schema-validator/src/test/java/org/ostelco/jsonschema/TestJsonValidation.kt new file mode 100644 index 000000000..44814ffc6 --- /dev/null +++ b/sim-administration/jersey-json-schema-validator/src/test/java/org/ostelco/jsonschema/TestJsonValidation.kt @@ -0,0 +1,30 @@ +package org.ostelco.jsonschema + +import org.json.JSONObject +import org.json.JSONTokener +import org.junit.Assert.assertNotNull +import org.junit.Test + +class TestJsonValidation { + + @Test + fun helloWorldTest() { + val inputStream = this.javaClass.getResourceAsStream("/hello-world-schema.json") + assertNotNull(inputStream) + val rawSchema = JSONObject(JSONTokener(inputStream)) + val schema = org.everit.json.schema.loader.SchemaLoader.load(rawSchema) + schema.validate(JSONObject("{\"hello\" : \"world\"}")) + } + + @Test + fun eS2DownloadOrderTest() { + val inputStream = this.javaClass.getResourceAsStream("/es2schemas/ES2+DownloadOrder-def.json") + assertNotNull(inputStream) + val rawSchema = JSONObject(JSONTokener(inputStream)) + val schema = org.everit.json.schema.loader.SchemaLoader.load(rawSchema) + schema.validate(JSONObject( "{\"eid\" : \"01234567890123456789012345678901\", \"iccid\" : \"01234567890123456789\", \"profileType\" : \"Eplestang\"}")) + } + + // TODO: This class does not contain any actual tests of the json schema validator. That is + // clearly something that should be fixed before we start believing in this code. +} \ No newline at end of file diff --git a/sim-administration/jersey-json-schema-validator/src/test/resources/es2schemas/ES2+DownloadOrder-def.json b/sim-administration/jersey-json-schema-validator/src/test/resources/es2schemas/ES2+DownloadOrder-def.json new file mode 100644 index 000000000..839caf04b --- /dev/null +++ b/sim-administration/jersey-json-schema-validator/src/test/resources/es2schemas/ES2+DownloadOrder-def.json @@ -0,0 +1,37 @@ +{ + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "functionRequesterIdentifier": { + "type": "string", + "description": "identification of the function requester" + }, + "functionCallIdentifier": { + "type": "string", + "description": "identification of the function call" + } + }, + "required": [ + "functionRequesterIdentifier", + "functionCallIdentifier" + ] + }, + "eid": { + "type": "string", + "pattern": "^[0-9]{32}$", + "description": "EID as desc in SGP.02" + }, + "iccid": { + "type": "string", + "pattern": "^[0-9]{19,20}$", + "description": "ICCID as desc in ITU-T E.118" + }, + "profileType": { + "type": "string", + "description": "content free information defined by the Operator" + } + } +} + diff --git a/sim-administration/jersey-json-schema-validator/src/test/resources/hello-world-schema.json b/sim-administration/jersey-json-schema-validator/src/test/resources/hello-world-schema.json new file mode 100644 index 000000000..2079c5b93 --- /dev/null +++ b/sim-administration/jersey-json-schema-validator/src/test/resources/hello-world-schema.json @@ -0,0 +1,7 @@ +{ + "type": "object", + "properties": { + "hello": { "type": "string" } + }, + "required": ["hello"] +} \ No newline at end of file diff --git a/sim-administration/ostelco-dropwizard-utils/build.gradle b/sim-administration/ostelco-dropwizard-utils/build.gradle new file mode 100644 index 000000000..e71a7e0a2 --- /dev/null +++ b/sim-administration/ostelco-dropwizard-utils/build.gradle @@ -0,0 +1,35 @@ +plugins { + id "org.jetbrains.kotlin.jvm" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "io.swagger.core.v3:swagger-jaxrs2:$swaggerVersion" + + + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-auth:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-jdbi:$dropwizardVersion" + + implementation 'org.conscrypt:conscrypt-openjdk-uber:1.4.2' + + testImplementation 'javax.activation:javax.activation-api:1.2.0' + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" + implementation "org.apache.commons:commons-csv:1.6" + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +apply from: '../../gradle/jacoco.gradle' \ No newline at end of file diff --git a/sim-administration/ostelco-dropwizard-utils/src/main/java/org/ostelco/dropwizardutils/CertificateAuthenticationAndAuthorization.kt b/sim-administration/ostelco-dropwizard-utils/src/main/java/org/ostelco/dropwizardutils/CertificateAuthenticationAndAuthorization.kt new file mode 100644 index 000000000..83869d62b --- /dev/null +++ b/sim-administration/ostelco-dropwizard-utils/src/main/java/org/ostelco/dropwizardutils/CertificateAuthenticationAndAuthorization.kt @@ -0,0 +1,416 @@ +package org.ostelco.dropwizardutils + +import com.fasterxml.jackson.annotation.JsonProperty +import org.eclipse.jetty.server.Authentication +import org.eclipse.jetty.server.UserIdentity +import java.io.IOException +import java.security.Principal +import java.security.cert.X509Certificate +import java.util.* +import javax.annotation.Priority +import javax.annotation.security.DenyAll +import javax.annotation.security.PermitAll +import javax.annotation.security.RolesAllowed +import javax.security.auth.Subject +import javax.servlet.http.HttpServletRequest +import javax.validation.Valid +import javax.validation.constraints.NotNull +import javax.ws.rs.Priorities +import javax.ws.rs.container.ContainerRequestContext +import javax.ws.rs.container.ResourceInfo +import javax.ws.rs.core.Context +import javax.ws.rs.core.Response +import javax.ws.rs.ext.Provider + + +/** + * A ContainerRequestFilter to do certificate validation beyond the tls validation. + * For example, the filter matches the subject against a regex and will 403 if it doesn't match + * + * + * + * In + * https://howtodoinjava.com/jersey/jersey-rest-security/ + * we can find an example of how to write an authentication filter + * from scatch, that reacts to annotations, roles, this that + * and misc. other things. It is all good, but will have to wait + * over the weekend. + */ + +class CertConfig { + // Userid, used in other parts of the permission system, e.g. when + // assigning roles etc. + @Valid + @JsonProperty("userId") + @NotNull + var userId: String? = null + + // All the X.509 identifying fields + // And so on for all the X.509 fields + // C=NO; L=Fornebu; O=Open Source Telco; CN=smdpplus.ostelco.org + @Valid + @JsonProperty("country") + @NotNull + var country: String? = null + + @Valid + @JsonProperty("state") + @NotNull + var state: String? = null + + @Valid + @JsonProperty("location") + @NotNull + var location: String? = null + + @Valid + @JsonProperty("organization") + @NotNull + var organization: String? = null + + @Valid + @JsonProperty("commonName") + @NotNull + var commonName: String? = null + + @Valid + @JsonProperty("roles") + @NotNull + var roles: MutableList = mutableListOf() + +} + +class RolesConfig { + @Valid + @JsonProperty("definitions") + @NotNull + + var roles: MutableList = mutableListOf() +} + +class RoleDef { + @Valid + @JsonProperty("name") + @NotNull + var name: String? = null + + @Valid + @JsonProperty("description") + @NotNull + var description: String? = null + +} + +class CertAuthConfig { + @Valid + @JsonProperty("certAuths") + @NotNull + var certAuths = mutableListOf() +} + +/** + * This filter verify the access permissions for a user + * based on a client certificate provided when authenticating the + * request. + */ +@Priority(Priorities.AUTHENTICATION) +@Provider +//@PreMatching // XXX Enable if possible +class CertificateAuthorizationFilter(private val rbac: RBACService) : javax.ws.rs.container.ContainerRequestFilter { + + @Context + private var resourceInfo: ResourceInfo? = null + + // Although this is a class level field, Jersey actually injects a proxy + // which is able to simultaneously serve more requests. + @Context + private var request: HttpServletRequest? = null + + private fun isUserAllowed(user: CertificateRBACUSER, rolesSet: Set): Boolean { + return rolesSet.intersect(user.roles.map{it.name}).isNotEmpty() + } + + companion object { + private val ACCESS_DENIED = Response.status(Response.Status.UNAUTHORIZED) + .entity("You cannot access this resource").build() + private val ACCESS_FORBIDDEN = Response.status(Response.Status.FORBIDDEN) + .entity("Access blocked for all users !!").build() + + private const val X509_CERTIFICATE_ATTRIBUTE = "javax.servlet.request.X509Certificate" + } + + // XXX https://stackoverflow.com/questions/34654903/how-to-create-global-and-pre-post-matching-filter-in-restlet + + private fun certificateMatches(requestContext: ContainerRequestContext): CertificateRBACUSER? { + + + val clientCert = extractClientCertFromRequest(requestContext) ?: return null + + val certParams = CertificateIdParameters.parse(clientCert) + + return rbac.findByCertParams(certParams) + } + + private fun extractClientCertFromRequest(requestContext: ContainerRequestContext): X509Certificate? { + val req = request + + if (req == null) { + requestContext.abortWith(buildForbiddenResponse("No request found!")) + return null + } + + val certificatesUncast = req.getAttribute(X509_CERTIFICATE_ATTRIBUTE) + if (certificatesUncast == null) { + requestContext.abortWith(buildForbiddenResponse("No certificate chain found!")) + return null + } + + val certificateChain = certificatesUncast as Array + + + if (certificateChain == null || certificateChain.isEmpty() || certificateChain[0] == null) { + requestContext.abortWith(buildForbiddenResponse("No certificate chain found!")) + return null + } + + // The certificate of the client is always the first in the chain. + + return certificateChain[0] + } + + @Throws(IOException::class) + override fun filter(requestContext: ContainerRequestContext) { + + /* Fast exit if not called with https scheme. + XXX: There must of course be a better way to do this, or? */ + + if ("http" == requestContext.uriInfo.baseUri.scheme) + return + + /// IMPLEMENT FULL RBOC (with stubbed out permissiveness matrix). + /// 1. Check certificate chain + /// 2. Get user from certificate (using config read from config file, later from rboc server?) + /// 3. From the user, and set of permissions and annotations on resourdes, + // calculate if the user has permission to do what he/she wants to do with the + // resource. + + val user = certificateMatches(requestContext) + + if (user == null) { + requestContext.abortWith(buildForbiddenResponse("Certificate subject is not recognized!")) + return + } + + val method = resourceInfo!!.resourceMethod + //Access allowed for all + if (method.isAnnotationPresent(PermitAll::class.java)) { + return + } + //Access denied for all + if (method.isAnnotationPresent(DenyAll::class.java)) { + requestContext.abortWith(ACCESS_FORBIDDEN) + return + } + + //Verify user access + if (method.isAnnotationPresent(RolesAllowed::class.java)) { + val rolesAnnotation = method.getAnnotation(RolesAllowed::class.java) + if (rolesAnnotation == null) { + requestContext.abortWith(ACCESS_DENIED) + return + } + val rolesSet = HashSet(Arrays.asList(*rolesAnnotation.value)) + + //Is user valid? + if (!isUserAllowed(user, rolesSet)) { + requestContext.abortWith(ACCESS_DENIED) + return + } + } + } + + private fun buildForbiddenResponse(message: String): Response { + return Response.status(Response.Status.FORBIDDEN) + .entity("{\"message\":\"$message\"}") + .build() + } +} + +class RBACUserPrincipal(private val id: String) : Principal { + override fun getName(): String { + return id + } +} + + +class RBACUserIdentity(id: String) : UserIdentity { + + private val principal: Principal + private val mySubject: Subject + + init { + this.principal = RBACUserPrincipal(id) + this.mySubject = Subject() + } + + override fun getSubject(): Subject { + return this.mySubject + } + + override fun isUserInRole(p0: String?, p1: UserIdentity.Scope?): Boolean { + return false + } + + override fun getUserPrincipal(): Principal { + return principal + } +} + +/** + * We're trying this out, not there yet. The intent is to move towards a proper + * RBAC system, so the role being referred to here is not really the same + * as RBAC would assume. + */ +data class CertificateRBACUSER( + val id: String, + val roles: Set, + val commonName: String, + val country: String, + val state: String, + val location: String, + val organization: String) : Authentication.User { + + private val userId: UserIdentity + + init { + userId = RBACUserIdentity(id) + } + + override fun isUserInRole(p0: UserIdentity.Scope?, p1: String?): Boolean { + return false + } + + override fun getUserIdentity(): UserIdentity { + return userId + } + + override fun getAuthMethod(): String { + return "CLIENT_CERTIFICATE" + } + + override fun logout() { + TODO("not implemented") + } + + fun asCertificateIdParamerters(): CertificateIdParameters { + return CertificateIdParameters(country = country, state = state, location = location, organization = organization, commonName = commonName) + } +} + +class RBACService(val rolesConfig: RolesConfig, val certConfig: CertAuthConfig) { + + + private val roles: MutableMap = mutableMapOf() + private val users: MutableMap = mutableMapOf() + + init { + rolesConfig.roles.forEach { + + if (roles.putIfAbsent(it.name!!, it) != null) { + throw RuntimeException("Multiple declarations of role ${it.name}") + } + } + + certConfig.certAuths.map { + val user = certAuthToUser(it) + users.put(user.id, user) + } + } + + private fun getRoleByName(name: String): RoleDef { + if (!roles.containsKey(name)) { + throw RuntimeException("Unknown role name $name") + } + + return roles[name]!! + } + + // R(val id: String, val commonName: String, val country: String, val state: String, val location: String, val organization: String) : Authentication.User { + private fun certAuthToUser(cc: CertConfig): CertificateRBACUSER { + + + val usersRoles = mutableSetOf() + + cc.roles.forEach { + if (roles.containsKey(it)) { + usersRoles.add(roles[it]!!) + } else { + throw RuntimeException("User ${cc.userId} claims to have role $it, but it doesn't exist") + } + } + + + return CertificateRBACUSER(id = cc.userId!!, roles = usersRoles, commonName = cc.commonName!!, country = cc.country!!, state = cc.state!!, location = cc.location!!, organization = cc.organization!!) + } + + fun findByCertParams(certParams: CertificateIdParameters): CertificateRBACUSER? { + return users.values.find { + val cpm = it.asCertificateIdParamerters() + + val match = cpm == certParams + match + } + } +} + +// CN=*.not-really-ostelco.org, O=Not really SMDP org, L=Oslo, ST=Oslo, C=NO +data class CertificateIdParameters(val commonName: String, val country: String, val state: String, val location: String, val organization: String) { + companion object { + fun parse(cert: X509Certificate): CertificateIdParameters { + + val inputString= cert.subjectDN.name + val parts = inputString.split(",") + + var countryName = "" + var commonName = "" + var location = "" + var organization = "" + var state = "" + + parts.forEach { + val split = it.split("=") + if (split.size != 2) { + throw RuntimeException("Illegal format for certificate") + } + val key = split[0].trim() + val value = split[1].trim() + + + + when (key) { + "CN" -> commonName = value + "C" -> countryName = value + "OU" -> { + } // organizational unit + "O" -> organization = value + // organization + "L" -> location = value + // locality + "S" -> state = value + // XXX State or province name + "ST" -> { + } // State or province name + } // State or province name + } + + return CertificateIdParameters( + commonName = commonName, + country = countryName, + location = location, + state = state, + organization = organization) + } + } +} + + diff --git a/sim-administration/ostelco-dropwizard-utils/src/main/java/org/ostelco/dropwizardutils/OpenapiResourceAdder.kt b/sim-administration/ostelco-dropwizard-utils/src/main/java/org/ostelco/dropwizardutils/OpenapiResourceAdder.kt new file mode 100644 index 000000000..e49c29017 --- /dev/null +++ b/sim-administration/ostelco-dropwizard-utils/src/main/java/org/ostelco/dropwizardutils/OpenapiResourceAdder.kt @@ -0,0 +1,70 @@ +package org.ostelco.dropwizardutils + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.jersey.setup.JerseyEnvironment +import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource +import io.swagger.v3.oas.integration.SwaggerConfiguration +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import java.util.stream.Collectors +import java.util.stream.Stream +import javax.validation.Valid +import javax.validation.constraints.NotNull + +/** + * Utility for adding an OpenAPI resource to your app. Adds an utility + * method to add the resource to a jersey environment, and a condfiguration + * class that must be referenced by your app's config class containing + * misc. metainformation about the API that is required by openAPI. + */ +class OpenapiResourceAdder { + companion object { + + fun addOpenapiResourceToJerseyEnv(jerseyEnvironment: JerseyEnvironment, config:OpenapiResourceAdderConfig ) { + val oas = OpenAPI() + val info = Info() + .title(config.name) + .description(config.description) + .termsOfService(config.termsOfService) + .contact(Contact().email(config.contactEmail)) + + oas.info(info) + val oasConfig = SwaggerConfiguration() + .openAPI(oas) + .prettyPrint(true) + .resourcePackages(Stream.of(config.resourcePackage) + .collect(Collectors.toSet())) + + jerseyEnvironment.register(OpenApiResource() + .openApiConfiguration(oasConfig)) + } + } +} + +class OpenapiResourceAdderConfig { + + @Valid + @NotNull + @JsonProperty("name") + var name: String = "" + + @Valid + @NotNull + @JsonProperty("description") + var description: String = "" + + @Valid + @NotNull + @JsonProperty("termsOfService") + var termsOfService: String = "" + + + @NotNull + @JsonProperty("contactEmail") + var contactEmail: String = "" + + @NotNull + @JsonProperty("resourcePackage") + var resourcePackage: String = "" +} \ No newline at end of file diff --git a/sim-administration/ostelco-dropwizard-utils/src/test/java/PingPongApp.kt b/sim-administration/ostelco-dropwizard-utils/src/test/java/PingPongApp.kt new file mode 100644 index 000000000..72c8144f5 --- /dev/null +++ b/sim-administration/ostelco-dropwizard-utils/src/test/java/PingPongApp.kt @@ -0,0 +1,116 @@ +package org.ostelco.simcards.smdpplus + + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import org.apache.http.client.HttpClient +import org.conscrypt.OpenSSLProvider + +import org.ostelco.dropwizardutils.CertAuthConfig +import org.ostelco.dropwizardutils.CertificateAuthorizationFilter +import org.ostelco.dropwizardutils.RBACService +import org.ostelco.dropwizardutils.RolesConfig +import java.security.Security +import javax.annotation.security.RolesAllowed +import javax.validation.Valid +import javax.validation.constraints.NotNull +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.Context +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.SecurityContext + + +class PingPongApp : Application() { + + + override fun getName(): String { + return "Dummy application implementing ping/pong protocol, for testing client certificate based authentication" + } + + override fun initialize(bootstrap: Bootstrap) { + // TODO: application initialization + } + + lateinit var client: HttpClient + + override fun run(config: PingPongAppConfiguration, + env: Environment) { + + val jerseyEnvironment = env.jersey() + + // XXX Only until we're sure the client stuff works. + jerseyEnvironment.register(PingResource()) + jerseyEnvironment.register( + CertificateAuthorizationFilter( + RBACService( + rolesConfig = config.rolesConfig, + certConfig = config.certConfig))) + + this.client = HttpClientBuilder(env).using( + config.httpClientConfiguration).build(name) + } + + companion object { + @Throws(Exception::class) + @JvmStatic + fun main(args: Array) { + Security.insertProviderAt(OpenSSLProvider(), 1) + PingPongApp().run(*args) + } + } +} + + +// XXX Can be removed once we're sure the client works well with +// encryption, and a test for that has been extended to work +// also for something other than ping. +@Path("/ping") +class PingResource { + + @RolesAllowed("flyfisher") + @GET + @Produces(MediaType.TEXT_PLAIN) + fun ping(//@Auth user: Authentication.User, + @Context context:SecurityContext ): String { + return "pong" + } +} + +/** + * Configuration class for SM-DP+ emulator. + */ +class PingPongAppConfiguration : Configuration() { + + /** + * The client we use to connect to other services, including + * ES2+ services + */ + @Valid + @NotNull + @JsonProperty("httpClient") + var httpClientConfiguration = HttpClientConfiguration() + + /** + * Declaring the mapping between users and certificates, also + * which roles the users are assigned to. + */ + @Valid + @JsonProperty("certAuth") + @NotNull + var certConfig = CertAuthConfig() + + /** + * Declaring which roles we will permit + */ + @Valid + @JsonProperty("roles") + @NotNull + var rolesConfig = RolesConfig() +} diff --git a/sim-administration/ostelco-dropwizard-utils/src/test/java/PingPongSslRoundtripTest.kt b/sim-administration/ostelco-dropwizard-utils/src/test/java/PingPongSslRoundtripTest.kt new file mode 100644 index 000000000..08f5bf310 --- /dev/null +++ b/sim-administration/ostelco-dropwizard-utils/src/test/java/PingPongSslRoundtripTest.kt @@ -0,0 +1,55 @@ +package org.ostelco.simcards.smdpplus + +import io.dropwizard.testing.DropwizardTestSupport +import org.apache.http.client.methods.HttpGet +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test + +class PingPongSslRoundtripTest { + + companion object { + val SUPPORT = DropwizardTestSupport( + PingPongApp::class.java, + "src/test/resources/config.yml" + ) + /* From config file. */ + const val HTTP_PORT = 8080 + const val TLS_PORT = 8443 + } + + @Before + fun setUp() { + SUPPORT.before() + } + + @After + fun tearDown() { + SUPPORT.after() + } + + @Test + fun handleNonEncryptedHttp() { + val client = SUPPORT.getApplication().client + val httpGet = HttpGet(String.format("http://localhost:%d/ping", HTTP_PORT)) + val response = client.execute(httpGet) + assertThat(response.statusLine.statusCode).isEqualTo(200) + } + + /** + * This now works, since we disabled hostname checking and enabled self-signed + * certificates in the config file. It would be nice if we could enable the + * hostname checks in the test, but I don't know exactly how to make that + * happen. + * + * https://www.baeldung.com/spring-boot-https-self-signed-certificate + */ + @Test + fun handleEncryptedHttp() { + val client = SUPPORT.getApplication().client + val httpGet = HttpGet(String.format("https://localhost:%d/ping", TLS_PORT)) + val response = client.execute(httpGet) + assertThat(response.statusLine.statusCode).isEqualTo(200) + } +} diff --git a/sim-administration/ostelco-dropwizard-utils/src/test/resources/config.yml b/sim-administration/ostelco-dropwizard-utils/src/test/resources/config.yml new file mode 100644 index 000000000..16b569bd3 --- /dev/null +++ b/sim-administration/ostelco-dropwizard-utils/src/test/resources/config.yml @@ -0,0 +1,56 @@ +logging: +# level: ALL + level: INFO + + +server: + adminMinThreads: 1 + adminMaxThreads: 64 + adminContextPath: / + applicationContextPath: / + applicationConnectors: + - type: http + port: 8080 + - type: https + port: 8443 + # Enabling conscrypt blows the whole thing up, so don't do that. + #jceProvider: Conscrypt + keyStoreType: JKS + keyStorePath: src/test/resources/sk_keys.jks + keyStorePassword: superSecreet + validateCerts: false + needClientAuth: true + wantClientAuth: true + supportedProtocols: [TLSv1.1, TLSv1.2, TLSv1.3] + excludedProtocols: [SSLv2Hello, SSLv3] + + +httpClient: + tls: + protocol: TLSv1.2 + keyStoreType: JKS + keyStorePath: src/test/resources/sk_keys.jks + keyStorePassword: superSecreet + verifyHostname: false + trustSelfSignedCertificates: true + + +# CN=*.not-really-ostelco.org, O=Not really SMDP org, L=Oslo, ST=Oslo, C=NO +certAuth: + certAuths: + - userId: MrFish + country: 'NO' + location: Oslo + state: '' + organization: Not really SMDP org + commonName: '*.not-really-ostelco.org' + roles: + - flyfisher + +roles: + definitions: + - name: flyfisher + description: Obviously just a dummy role + + + diff --git a/sim-administration/ostelco-dropwizard-utils/src/test/resources/sk_keys.jks b/sim-administration/ostelco-dropwizard-utils/src/test/resources/sk_keys.jks new file mode 100644 index 000000000..8e2775bf1 Binary files /dev/null and b/sim-administration/ostelco-dropwizard-utils/src/test/resources/sk_keys.jks differ diff --git a/sim-administration/simcard-utils/build.gradle b/sim-administration/simcard-utils/build.gradle new file mode 100644 index 000000000..de3a70bcb --- /dev/null +++ b/sim-administration/simcard-utils/build.gradle @@ -0,0 +1,11 @@ +plugins { + id "org.jetbrains.kotlin.jvm" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" +} + +apply from: '../../gradle/jacoco.gradle' \ No newline at end of file diff --git a/sim-administration/simcard-utils/src/main/java/org/ostelco/simcards/IccidBasis.kt b/sim-administration/simcard-utils/src/main/java/org/ostelco/simcards/IccidBasis.kt new file mode 100644 index 000000000..38ae31237 --- /dev/null +++ b/sim-administration/simcard-utils/src/main/java/org/ostelco/simcards/IccidBasis.kt @@ -0,0 +1,16 @@ +package org.ostelco.simcards + +import org.ostelco.simcards.LuhnChecksum.Companion.luhnComplete + +/** + * MM = Constant (ISO 7812 Major Industry Identifier) + * CC = Country Code + * II = Issuer Identifier + * serialNumber = unique positive number. + */ +class IccidBasis(private val mm: Int = 89, val cc: Int = 1, private val ii: Int = 0, val serialNumber: Int) { + fun asIccid(): String { + val protoIccid = "%02d%02d%02d%012d".format(mm, cc, ii, serialNumber) + return luhnComplete(protoIccid) + } +} diff --git a/sim-administration/simcard-utils/src/main/java/org/ostelco/simcards/Luhn.kt b/sim-administration/simcard-utils/src/main/java/org/ostelco/simcards/Luhn.kt new file mode 100644 index 000000000..50047e6b5 --- /dev/null +++ b/sim-administration/simcard-utils/src/main/java/org/ostelco/simcards/Luhn.kt @@ -0,0 +1,57 @@ +package org.ostelco.simcards + + +/** + * Impldementing the Luhn checksum used in ICCIDs and + * credit cards. https://en.wikipedia.org/wiki/Luhn_algorithm + */ +class LuhnChecksum { + companion object { + /** + * Implement the Luhn algorithm for checksums. + * Assume that the ccNumber is a string representing + * a base 10 number. + * TODO: Implement check that input string is a valid base 10 number. + */ + fun luhnCheck(ccNumber: String): Boolean { + var sum = 0 + var alternate = false + for (i in ccNumber.length - 1 downTo 0) { + var n = Integer.parseInt(ccNumber.substring(i, i + 1)) + if (alternate) { + n *= 2 + if (n > 9) { + n = n % 10 + 1 + } + } + sum += n + alternate = !alternate + } + return sum % 10 == 0 + } + + /** + * Assuming that the string is baser 10 number, append a digit in the range 0 to 9 + * to make it a valid Luhn compliant number. + */ + fun luhnComplete(s: String): String { + for (c in listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")) { + val candidate = "$s$c" + if (luhnCheck(candidate)) { + return candidate + } + } + throw LuhnException("Luhn completion failed for string '$s'") + } + } +} + +/** + * Exception that is thrown when the Luhn algorithm/algorithms encounter + * unrecoverable errors. + */ +class LuhnException(message: String) : Exception(message) + + + + diff --git a/sim-administration/simcard-utils/src/test/java/org/ostelco/simcards/LuhnTests.kt b/sim-administration/simcard-utils/src/test/java/org/ostelco/simcards/LuhnTests.kt new file mode 100644 index 000000000..31d08262e --- /dev/null +++ b/sim-administration/simcard-utils/src/test/java/org/ostelco/simcards/LuhnTests.kt @@ -0,0 +1,38 @@ +package org.ostelco.simcards + +import junit.framework.TestCase +import org.junit.Test +import org.ostelco.simcards.LuhnChecksum.Companion.luhnCheck +import org.ostelco.simcards.LuhnChecksum.Companion.luhnComplete + + +class LuhnTests { + + @Test + fun testNegativeLuhnCheck() { + for (x in listOf( + "79927398710", + "79927398711", + "79927398712", + "79927398714", + "79927398715", + "79927398716", + "79927398717", + "79927398718", + "79927398719")) { + TestCase.assertFalse(luhnCheck(x)) + } + } + + @Test + fun testPositiveLuhnCheck() { + for (x in listOf("79927398713")) { + TestCase.assertTrue(luhnCheck(x)) + } + } + + @Test + fun testLuhnComplete() { + TestCase.assertTrue(luhnCheck(luhnComplete("79927398710"))) + } +} \ No newline at end of file diff --git a/sim-administration/simmanager/build.gradle b/sim-administration/simmanager/build.gradle new file mode 100644 index 000000000..b7fd2ade0 --- /dev/null +++ b/sim-administration/simmanager/build.gradle @@ -0,0 +1,113 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "idea" + id "com.github.johnrengelman.shadow" version "5.0.0" + id "com.google.protobuf" version "0.8.8" +} + +dependencies { + + implementation project(":prime-modules") + + implementation project(":sim-administration:jersey-json-schema-validator") + implementation project(":sim-administration:simcard-utils") + implementation project(":sim-administration:es2plus4dropwizard") + implementation project(":sim-administration:ostelco-dropwizard-utils") + implementation project(":sim-administration:sm-dp-plus") + + // Arrow + api "io.arrow-kt:arrow-core:$arrowVersion" + api "io.arrow-kt:arrow-typeclasses:$arrowVersion" + api "io.arrow-kt:arrow-instances-core:$arrowVersion" + api "io.arrow-kt:arrow-effects:$arrowVersion" + + // Grpc + api "io.grpc:grpc-netty-shaded:$grpcVersion" + api "io.grpc:grpc-protobuf:$grpcVersion" + api "io.grpc:grpc-stub:$grpcVersion" + api "io.grpc:grpc-core:$grpcVersion" + + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + // Dropwizared + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-jdbi3:$dropwizardVersion" + implementation "io.dropwizard.metrics:metrics-core:$metricsVersion" + implementation ("com.google.guava:guava:$guavaVersion") { + force = true + } + + // Jdbi3 plugins. + // Ver. 3.2.1 as ver. 1.3.8 of dropwizard loads jdbi3-core ver. 3.2.1 etc. + implementation "org.jdbi:jdbi3-kotlin:3.2.1" + implementation "org.jdbi:jdbi3-kotlin-sqlobject:3.2.1" + implementation "org.jdbi:jdbi3-postgres:3.2.1" + + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" + implementation "org.apache.commons:commons-csv:1.6" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0" + testImplementation "org.testcontainers:postgresql:1.10.5" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +sourceSets { + integration { + kotlin { + compileClasspath += main.output + test.output + runtimeClasspath += main.output + test.output + srcDirs += file('src/integration-test/kotlin') + } + resources.srcDir file('src/integration-test/resources') + } +} + +configurations { + integration + integrationImplementation.extendsFrom testImplementation + integrationCompile.extendsFrom testCompile + integrationRuntime.extendsFrom testRuntime +} + +protobuf { + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + + protoc { artifact = "com.google.protobuf:protoc:$protocVersion" } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} + + +task integration(type: Test, description: 'Runs the integration tests.', group: 'Verification') { + testClassesDirs = sourceSets.integration.output.classesDirs + classpath = sourceSets.integration.runtimeClasspath +} + +build.dependsOn integration + +apply from: '../../gradle/jacoco.gradle' + +idea { + module { + testSourceDirs += file('src/integration-test/kotlin') + sourceDirs += files("${protobuf.generatedFilesBaseDir}/main/java") + sourceDirs += files("${protobuf.generatedFilesBaseDir}/main/grpc") + } +} + + diff --git a/sim-administration/simmanager/config.yml b/sim-administration/simmanager/config.yml new file mode 100644 index 000000000..c7fa44b05 --- /dev/null +++ b/sim-administration/simmanager/config.yml @@ -0,0 +1,48 @@ +logging: + level: INFO + +openApi: + name: SIM admin + description: SIM administration service + termsOfService: http://example.org + contactEmail: rmz@telenordigital.com + resourcePackage: org.ostelco + +hlrs: + - name: loltel + endpoint: ${WG2_ENDPOINT} + userId: ${WG2_USER} + apiKey: ${WG2_API_KEY} + +profileVendors: + - name: Idemia + es2plusEndpoint: ${ES2PLUS_ENDPOINT} + requesterIdentifier: ${FUNCTION_REQUESTER_IDENTIFIER} + es9plusEndpoint: ${ES9PLUS_ENDPOINT} + +# Note, list must end with a wildcard match +phoneTypes: + - regex: "android.*" + profile: Loltel_ANDROID_1 + - regex: "iphone.*" + profile: LOLTEL_IPHONE_1 + - regex: ".*" + profile: LOLTEL_IPHONE_1 + +database: + driverClass: org.postgresql.Driver + user: ${DB_USER} + password: ${DB_PASSWORD} + url: ${DB_URL} + +httpClient: + timeout: 10000ms + tls: + # Default is 500 milliseconds, we need more when debugging. + # protocol: TLSv1.2 + keyStoreType: JKS + keyStorePath: /certs/idemia-client-cert.jks + keyStorePassword: foobar + verifyHostname: false + trustSelfSignedCertificates: true + \ No newline at end of file diff --git a/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/ClearTablesForTesting.kt b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/ClearTablesForTesting.kt new file mode 100644 index 000000000..1b54dbcce --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/ClearTablesForTesting.kt @@ -0,0 +1,36 @@ +package org.ostelco.simcards.admin + +import org.jdbi.v3.sqlobject.statement.SqlUpdate + +/** + * Clear tables. This library shouldn't be part of normal + * running code, should be part of the test harness. + */ +interface ClearTablesForTestingDB { + + @SqlUpdate("TRUNCATE sim_import_batches") + abstract fun truncateImportBatchesTable() + + @SqlUpdate("TRUNCATE sim_entries") + abstract fun truncateSimEntryTable() + + @SqlUpdate("TRUNCATE hlr_adapters") + abstract fun truncateHlrAdapterTable() + + @SqlUpdate("TRUNCATE profile_vendor_adapters") + abstract fun truncateProfileVendorAdapterTable() + + @SqlUpdate("TRUNCATE sim_vendors_permitted_hlrs") + abstract fun truncateSimVendorsPermittedTable() +} + +class ClearTablesForTestingDAO(private val db: ClearTablesForTestingDB) { + + fun clearTables() { + db.truncateImportBatchesTable() + db.truncateSimEntryTable() + db.truncateHlrAdapterTable() + db.truncateProfileVendorAdapterTable() + db.truncateSimVendorsPermittedTable() + } +} \ No newline at end of file diff --git a/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/SimAdministrationApplication.kt b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/SimAdministrationApplication.kt new file mode 100644 index 000000000..e33c534d3 --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/SimAdministrationApplication.kt @@ -0,0 +1,50 @@ +package org.ostelco.simcards.admin + +import io.dropwizard.Application +import io.dropwizard.configuration.EnvironmentVariableSubstitutor +import io.dropwizard.configuration.SubstitutingSourceProvider +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment + + +/** + * The SIM manager test application + * is an application that inputs inhales SIM batches + * from SIM profile factories (physical or esim). It then facilitates + * activation of SIM profiles to MSISDNs. A typical interaction is + * "find me a sim profile for this MSISDN for this HLR" , and then + * "activate that profile". The activation will typically involve + * at least talking to a HLR to permit user equipment to use the + * SIM profile to authenticate, and possibly also an SM-DP+ to + * activate a SIM profile (via its ICCID and possible an EID). + * The inventory can then serve as an intermidiary between the + * rest of the BSS and the OSS in the form of HSS and SM-DP+. + */ +class SimAdministrationApplication : Application() { + + + private val simAdminModule = SimAdministrationModule() + + override fun getName(): String { + return "SIM inventory application" + } + + + override fun initialize(bootstrap: Bootstrap) { + /* Enables ENV variable substitution in config file. */ + bootstrap.configurationSourceProvider = SubstitutingSourceProvider( + bootstrap.configurationSourceProvider, + EnvironmentVariableSubstitutor(false) + ) + } + + override fun run(config: SimAdministrationConfiguration, + env: Environment) { + simAdminModule.setConfig(config) + simAdminModule.init(env) + } + + fun getDAO() = simAdminModule.getDAO() +} + + diff --git a/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/SimAdministrationTest.kt b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/SimAdministrationTest.kt new file mode 100644 index 000000000..b2afca122 --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/admin/SimAdministrationTest.kt @@ -0,0 +1,304 @@ +package org.ostelco.simcards.admin + +import com.codahale.metrics.health.HealthCheck +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.JerseyClientBuilder +import io.dropwizard.jdbi3.JdbiFactory +import io.dropwizard.testing.ConfigOverride +import io.dropwizard.testing.ResourceHelpers +import io.dropwizard.testing.junit.DropwizardAppRule +import org.assertj.core.api.Assertions.assertThat +import org.glassfish.jersey.client.ClientProperties +import org.jdbi.v3.core.Jdbi +import org.junit.* +import org.junit.Assert.assertEquals +import org.ostelco.simcards.hss.DirectHssDispatcher +import org.ostelco.simcards.hss.HealthCheckRegistrar +import org.ostelco.simcards.hss.SimManagerToHssDispatcherAdapter +import org.ostelco.simcards.inventory.* +import org.ostelco.simcards.smdpplus.SmDpPlusApplication +import org.testcontainers.containers.BindMode +import org.testcontainers.containers.FixedHostPortGenericContainer +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy +import java.io.FileInputStream +import java.time.Duration +import java.time.temporal.ChronoUnit +import javax.ws.rs.client.Client +import javax.ws.rs.client.Entity +import javax.ws.rs.core.MediaType + + +class SimAdministrationTest { + + companion object { + private lateinit var jdbi: Jdbi + private lateinit var client: Client + + /* Port number exposed to host by the emulated HLR service. */ + private var HLR_PORT = (20_000..29_999).random() + + @JvmField + @ClassRule + val psql: KPostgresContainer = KPostgresContainer("postgres:11-alpine") + .withInitScript("init.sql") + .withDatabaseName("sim_manager") + .withUsername("test") + .withPassword("test") + .withExposedPorts(5432) + .waitingFor(LogMessageWaitStrategy() + .withRegEx(".*database system is ready to accept connections.*\\s") + .withTimes(2) + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS))) + + init { + psql.start() + } + + @JvmField + @ClassRule + val SM_DP_PLUS_RULE = DropwizardAppRule(SmDpPlusApplication::class.java, + ResourceHelpers.resourceFilePath("sm-dp-plus.yaml")) + + @JvmField + @ClassRule + val HLR_RULE: KFixedHostPortGenericContainer = KFixedHostPortGenericContainer("python:3-alpine") + .withFixedExposedPort(HLR_PORT, 8080) + .withExposedPorts(8080) + .withClasspathResourceMapping("hlr.py", "/service.py", + BindMode.READ_ONLY) + .withCommand( "python", "/service.py") + + @JvmField + @ClassRule + val SIM_MANAGER_RULE = DropwizardAppRule(SimAdministrationApplication::class.java, + ResourceHelpers.resourceFilePath("sim-manager.yaml"), + ConfigOverride.config("database.url", psql.jdbcUrl), + ConfigOverride.config("hlrs[0].endpoint", "http://localhost:$HLR_PORT/default/provision")) + + @BeforeClass + @JvmStatic + fun setUpDb() { + jdbi = JdbiFactory() + .build(SIM_MANAGER_RULE.environment, SIM_MANAGER_RULE.configuration.database, + "db") + .installPlugins() + } + + @BeforeClass + @JvmStatic + fun setUpClient() { + client = JerseyClientBuilder(SIM_MANAGER_RULE.environment) + .withProperty(ClientProperties.READ_TIMEOUT, 5000) + .build("test client") + } + } + + /* Kotlin type magic from: + https://arnabmitra.github.io/jekyll/update/2018/01/18/TestContainers.html */ + class KPostgresContainer(imageName: String) : + PostgreSQLContainer(imageName) + + class KFixedHostPortGenericContainer(imageName: String) : + FixedHostPortGenericContainer(imageName) + + private val hssName = "Foo" + private val profileVendor = "Bar" + private val phoneType = "rababara" + private val expectedProfile = "IPHONE_PROFILE_2" + + /* Test endpoint. */ + private val simManagerEndpoint = "http://localhost:${SIM_MANAGER_RULE.localPort}/ostelco/sim-inventory" + + /* Generate a fixed corresponding EID based on ICCID. + Same code is used in SM-DP+ emulator. */ + private fun getEidFromIccid(iccid: String): String? = if (iccid.isNotEmpty()) + "01010101010101010101" + iccid.takeLast(12) + else + null + + /** + * Set up SIM Manager DB with test data by reading the 'sample-sim-batch.csv' and + * load the data to the DB using the SIM Manager 'import-batch' API. + */ + + @Before + fun setUp() { + SM_DP_PLUS_RULE.getApplication().reset() + clearTables() + presetTables() + loadSimData() + } + + private fun clearTables() { + val dao = ClearTablesForTestingDAO(jdbi.onDemand(ClearTablesForTestingDB::class.java)) + + dao.clearTables() + } + + private fun presetTables() { + val dao = SIM_MANAGER_RULE.getApplication().getDAO() + + dao.addProfileVendorAdapter(profileVendor) + dao.addHssEntry(hssName) + dao.permitVendorForHssByNames(profileVendor = profileVendor, hssName = hssName) + } + + /* The SIM dataset is the same that is used by the SM-DP+ emulator. */ + private fun loadSimData() { + val entries = FileInputStream(SM_DP_PLUS_RULE.configuration.simBatchData) + val response = client.target("$simManagerEndpoint/$hssName/import-batch/profilevendor/$profileVendor") + .request() + .put(Entity.entity(entries, MediaType.TEXT_PLAIN)) + assertThat(response.status).isEqualTo(200) + } + + /* TODO: SM-DP+ emuluator must be extended to support the 'getProfileStatus' + message before this test can be enabled. */ + @Test + @Ignore + fun testGetProfileStatus() { + val iccid = "8901000000000000001" + val response = client.target("$simManagerEndpoint/$hssName/profileStatusList/$iccid") + .request() + .get() + assertThat(response.status).isEqualTo(200) + } + + @Test + fun testGetIccid() { + val iccid = "8901000000000000001" + val response = client.target("$simManagerEndpoint/$hssName/iccid/$iccid") + .request() + .get() + assertThat(response.status).isEqualTo(200) + + val simEntry = response.readEntity(SimEntry::class.java) + assertThat(simEntry.iccid).isEqualTo(iccid) + } + + /* A freshly loaded DB don't have any SIM entries set + up as a ready to use eSIM. */ + @Test + fun testNoReadyToUseEsimAvailable() { + val response = client.target("$simManagerEndpoint/$hssName/esim") + .request() + .get() + assertThat(response.status).isEqualTo(404) + } + + /// + /// Tests related to the cron job that will allocate new SIM cards + /// as they are required. + /// + + @Test + fun testGetListOfHlrs() { + val simDao = SIM_MANAGER_RULE.getApplication() + .getDAO() + val hssEntries = simDao.getHssEntries() + + hssEntries.mapRight { assertEquals(1, it.size) } + hssEntries.mapRight { assertEquals(hssName, it[0].name) } + } + + @Test + fun testGetProfilesForHlr() { + val simDao = SIM_MANAGER_RULE.getApplication() + .getDAO() + val hlrs = simDao.getHssEntries() + assertThat(hlrs.isRight()).isTrue() + + var hlrId: Long = 0 + hlrs.map { + hlrId = it[0].id + } + + val profiles = simDao.getProfileNamesForHssById(hlrId) + assertThat(profiles.isRight()).isTrue() + profiles.map { + assertEquals(1, it.size) + assertEquals(expectedProfile, it[0]) + } + } + + @Test + fun testGetProfileStats() { + val simDao = SIM_MANAGER_RULE.getApplication() + .getDAO() + val hlrs = simDao.getHssEntries() + assertThat(hlrs.isRight()).isTrue() + + var hlrId: Long = 0 + hlrs.map { + hlrId = it[0].id + } + + val stats = simDao.getProfileStats(hlrId, expectedProfile) + assertThat(stats.isRight()).isTrue() + stats.map { + assertEquals(100L, it.noOfEntries) + assertEquals(100L, it.noOfUnallocatedEntries) + assertEquals(0L, it.noOfReleasedEntries) + } + } + + @Test + fun testPeriodicProvisioningTask() { + val simDao = SIM_MANAGER_RULE.getApplication() + .getDAO() + + val profileVendors = SIM_MANAGER_RULE.configuration.profileVendors + val hssConfigs = SIM_MANAGER_RULE.configuration.hssVendors + val httpClient = HttpClientBuilder(SIM_MANAGER_RULE.environment) + .build("periodicProvisioningTaskClient") + val maxNoOfProfilesToAllocate = 10 + + val hlrs = simDao.getHssEntries() + assertThat(hlrs.isRight()).isTrue() + + var hssId: Long = 0 + hlrs.map { + hssId = it[0].id + } + + val dispatcher = DirectHssDispatcher( + hssConfigs = hssConfigs, + httpClient = httpClient, + healthCheckRegistrar = object : HealthCheckRegistrar { + override fun registerHealthCheck(name: String, healthCheck: HealthCheck) { + SIM_MANAGER_RULE.environment.healthChecks().register(name, healthCheck) + } + }) + val hssAdapterCache = SimManagerToHssDispatcherAdapter( + dispatcher = dispatcher , + simInventoryDAO = simDao) + val preStats = SimProfileKeyStatistics( + 0L, + 0L, + 0L, + 0L) + val task = PreallocateProfilesTask( + profileVendors = profileVendors, + simInventoryDAO = simDao, + maxNoOfProfileToAllocate = maxNoOfProfilesToAllocate, + hssAdapterProxy = hssAdapterCache, + httpClient = httpClient) + task.preAllocateSimProfiles() + + val postAllocationStats = + simDao.getProfileStats(hssId, expectedProfile) + assertThat(postAllocationStats.isRight()).isTrue() + + var postStats = SimProfileKeyStatistics(0L, 0L, 0L, 0L) + postAllocationStats.map { + postStats = it + } + + val noOfAllocatedProfiles = + postStats.noOfEntriesAvailableForImmediateUse - preStats.noOfEntriesAvailableForImmediateUse + assertEquals( + maxNoOfProfilesToAllocate.toLong(), + noOfAllocatedProfiles) + } +} diff --git a/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt new file mode 100644 index 000000000..86d0374b3 --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/kotlin/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt @@ -0,0 +1,338 @@ +package org.ostelco.simcards.smdpplus + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import org.apache.http.client.HttpClient +import org.ostelco.dropwizardutils.* +import org.ostelco.dropwizardutils.OpenapiResourceAdder.Companion.addOpenapiResourceToJerseyEnv +import org.ostelco.sim.es2plus.* +import org.ostelco.sim.es2plus.ES2PlusIncomingHeadersFilter.Companion.addEs2PlusDefaultFiltersAndInterceptors +import org.slf4j.LoggerFactory +import java.io.FileInputStream +import javax.validation.Valid +import javax.validation.constraints.NotNull + + +/** + * NOTE: This is not a proper SM-DP+ application, it is a test fixture + * to be used when accpetance-testing the sim administration application. + * + * The intent of the SmDpPlusApplication is to be run in Docker Compose, + * to serve a few simple ES2+ commands, and to do so consistently, and to + * report back to the sim administration application via ES2+ callback, as to + * exercise that part of the protocol as well. + * + * In no shape or form is this intended to be a proper SmDpPlus application. It + * does not store sim profiles, it does not talk ES9+ or ES8+ or indeed do + * any of the things that would be useful for serving actual eSIM profiles. + * + * With those caveats in mind, let's go on to the important task of making a simplified + * SM-DP+ that can serve as a test fixture :-) + */ +class SmDpPlusApplication : Application() { + + override fun getName(): String { + return "SM-DP+ implementation (partial, only for testing of sim admin service)" + } + + override fun initialize(bootstrap: Bootstrap) { + // TODO: application initialization + } + + private lateinit var httpClient: HttpClient + + private lateinit var es2plusClient: ES2PlusClient + + private lateinit var serverResource: SmDpPlusServerResource + + + private lateinit var smdpPlusService: SmDpPlusEmulator + + override fun run(config: SmDpPlusAppConfiguration, + env: Environment) { + + val jerseyEnvironment = env.jersey() + + addOpenapiResourceToJerseyEnv(jerseyEnvironment, config.openApi) + addEs2PlusDefaultFiltersAndInterceptors(jerseyEnvironment) + + val simEntriesIterator = SmDpSimEntryIterator(FileInputStream(config.simBatchData)) + this.smdpPlusService = SmDpPlusEmulator(simEntriesIterator) + + this.serverResource = SmDpPlusServerResource( + smDpPlus = smdpPlusService) + jerseyEnvironment.register(serverResource) + jerseyEnvironment.register(CertificateAuthorizationFilter(RBACService( + rolesConfig = config.rolesConfig, + certConfig = config.certConfig))) + + + jerseyEnvironment.register(CertificateAuthorizationFilter( + RBACService(rolesConfig = config.rolesConfig, + certConfig = config.certConfig))) + + this.httpClient = HttpClientBuilder(env).using(config.httpClientConfiguration).build(name) + this.es2plusClient = ES2PlusClient( + requesterId = config.es2plusConfig.requesterId, + host = config.es2plusConfig.host, + port = config.es2plusConfig.port, + httpClient = httpClient) + } + + fun reset() { + this.smdpPlusService.reset(); + } +} + +/** + * A very reduced functionality SmDpPlus, essentially handling only + * happy day scenarios, and not particulary efficient, and in-memory + * only etc. + */ +class SmDpPlusEmulator(incomingEntries: Iterator) : SmDpPlusService { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Global lock, just in case. + */ + private val entriesLock = Object() + + private val entries: MutableSet = mutableSetOf() + private val entriesByIccid = mutableMapOf() + private val entriesByImsi = mutableMapOf() + private val entriesByProfile = mutableMapOf>() + + private val originalEntries : MutableSet = mutableSetOf() + + init { + incomingEntries.forEach { originalEntries.add(it) } + + log.info("Just read ${entries.size} SIM entries.") + } + + fun reset() { + entries.clear() + entriesByIccid.clear() + entriesByProfile.clear() + entriesByImsi.clear() + + originalEntries.map{it.clone()}.forEach { + entries.add(it) + entriesByIccid[it.iccid] = it + entriesByImsi[it.imsi] = it + val entriesForProfile: MutableSet + if (!entriesByProfile.containsKey(it.profile)) { + entriesForProfile = mutableSetOf() + entriesByProfile[it.profile] = entriesForProfile + } else { + entriesForProfile = entriesByProfile[it.profile]!! + } + entriesForProfile.add(it) + } + + // Just checking. This shouldn't happen, but if the original entries were not + // properly copied by toList, it could heasily happen. + entries.forEach { if (it.allocated) throw RuntimeException("Already allocated new entry $it")} + } + + + // TODO; What about the reservation flag? + override fun downloadOrder(eid: String?, iccid: String?, profileType: String?): Es2DownloadOrderResponse { + synchronized(entriesLock) { + val entry: SmDpSimEntry = findMatchingFreeProfile(iccid, profileType) + ?: throw SmDpPlusException("Could not find download order matching criteria") + + // If an EID is known, then mark this as the IED associated + // with the entry. + if (eid != null) { + entry.eid = eid + } + + // Then mark the entry as allocated and return the corresponding ICCID. + entry.allocated = true + + // Finally return the ICCID uniquely identifying the profile instance. + return Es2DownloadOrderResponse(eS2SuccessResponseHeader(), + iccid = entry.iccid) + } + } + + /** + * Find a free profile that either matches both profileStatusList and profile type (if profileStatusList != null), + * or just profile type (if profileStatusList == null). Throw runtime exception if parameter + * errors are discovered, but return null if no matching profile is found. + */ + private fun findMatchingFreeProfile(iccid: String?, profileType: String?): SmDpSimEntry? { + return if (iccid != null) { + findUnallocatedByIccidAndProfileType(iccid, profileType) + } else if (profileType == null) { + throw RuntimeException("No profileStatusList, no profile type, so don't know how to allocate sim entry") + } else if (!entriesByProfile.containsKey(profileType)) { + throw SmDpPlusException("Unknown profile type $profileType") + } else { + allocateByProfile(profileType) + } + } + + /** + * Find an allocatable profile by profile type. If a free and matching profile can be found. If not, then + * return null. + */ + private fun allocateByProfile(profileType: String): SmDpSimEntry? { + val entriesForProfile = entriesByProfile[profileType] ?: return null + return entriesForProfile.find { !it.allocated } + } + + /** + * Allocate by ICCID, but only do so if the profileStatusList exists, and the + * profile associated with that ICCID matches the expected profile type + * (if not null, null will match anything). + */ + private fun findUnallocatedByIccidAndProfileType(iccid: String, profileType: String?): SmDpSimEntry { + if (!entriesByIccid.containsKey(iccid)) { + throw RuntimeException("Attempt to allocate nonexisting profileStatusList $iccid") + } + + val entry = entriesByIccid[iccid]!! + + if (entry.allocated) { + throw SmDpPlusException("Attempt to download an already allocated SIM entry") + } + + if (profileType != null) { + if (entry.profile != profileType) { + throw SmDpPlusException("Profile of profileStatusList = $iccid is ${entry.profile}, not $profileType") + } + } + return entry + } + + /** + * Generate a fixed corresponding EID based on ICCID. + * XXX Whoot? + **/ + private fun getEidFromIccid(iccid: String): String? = if (iccid.isNotEmpty()) + "01010101010101010101" + iccid.takeLast(12) + else + null + + override fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag: Boolean): Es2ConfirmOrderResponse { + + if (iccid == null) { + throw RuntimeException("No ICCD, cannot confirm order") + } + if (!entriesByIccid.containsKey(iccid)) { + throw RuntimeException("Attempt to allocate nonexisting profileStatusList $iccid") + } + val entry = entriesByIccid[iccid]!! + + + if (smdsAddress != null) { + entry.smdsAddress = smdsAddress + } + + if (machingId != null) { + entry.machingId = confirmationCode + } else { + entry.machingId = "0123-ABC-KGBC-IAMOS-SAD0" /// XXX This is obviously bogus code! + } + + entry.released = releaseFlag + + if (confirmationCode != null) { + entry.confirmationCode = confirmationCode + } + + val eidReturned = if (eid.isNullOrEmpty()) + getEidFromIccid(iccid) + else + eid + + return Es2ConfirmOrderResponse(eS2SuccessResponseHeader(), + eid = eidReturned!!, + smdsAddress = entry.smdsAddress, + matchingId = entry.machingId) + } + + override fun cancelOrder(eid: String?, iccid: String?, matchingId: String?, finalProfileStatusIndicator: String?) { + TODO("not implemented") + } + + override fun releaseProfile(iccid: String) { + TODO("not implemented") + } + + +} + +/** + * Thrown when an non-recoverable error is encountered byt he sm-dp+ implementation. + */ +class SmDpPlusException(message: String) : Exception(message) + + +/** + * Configuration class for SM-DP+ emulator. + */ +class SmDpPlusAppConfiguration : Configuration() { + + /** + * Configuring how the Open API representation of the + * served resources will be presenting itself (owner, + * license etc.) + */ + @Valid + @NotNull + @JsonProperty("es2plusClient") + var es2plusConfig = EsTwoPlusConfig() + + /** + * Configuring how the Open API representation of the + * served resources will be presenting itself (owner, + * license etc.) + */ + @Valid + @NotNull + @JsonProperty("openApi") + var openApi = OpenapiResourceAdderConfig() + + /** + * Path to file containing simulated SIM data. + */ + @Valid + @NotNull + @JsonProperty("simBatchData") + var simBatchData: String = "" + + /** + * The httpClient we use to connect to other services, including + * ES2+ services + */ + @Valid + @NotNull + @JsonProperty("httpClient") + var httpClientConfiguration = HttpClientConfiguration() + + /** + * Declaring the mapping between users and certificates, also + * which roles the users are assigned to. + */ + @Valid + @JsonProperty("certAuth") + @NotNull + var certConfig = CertAuthConfig() + + /** + * Declaring which roles we will permit + */ + @Valid + @JsonProperty("roles") + @NotNull + var rolesConfig = RolesConfig() +} diff --git a/sim-administration/simmanager/src/integration-test/resources/hlr.py b/sim-administration/simmanager/src/integration-test/resources/hlr.py new file mode 100644 index 000000000..cf3a83495 --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/resources/hlr.py @@ -0,0 +1,52 @@ +#! /usr/bin/python + +# Emulates a generic HLR service for use in integration tests. + +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = int(os.getenv("PORT", "8080")) + +class handler(BaseHTTPRequestHandler): + + def do_GET(self): + paths = [ + '/ping', + ] + if self.path in paths: + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + self.wfile.write(bytes('pong', 'UTF-8')) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + paths = [ + '/default/provision/activate', + ] + if self.path in paths and self.path.endswith('/activate'): + self.send_response(201) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + def do_DELETE(self): + paths = [ + '/default/provision/deactivate', + ] + front, _ = self.path.rsplit('/', 1) + if any(front in p for p in paths) and front.endswith('/deactivate'): + self.send_response(200) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + +if __name__ == '__main__': + server_class = HTTPServer + httpd = server_class(("", PORT), handler) + httpd.serve_forever() diff --git a/sim-administration/simmanager/src/integration-test/resources/init.sql b/sim-administration/simmanager/src/integration-test/resources/init.sql new file mode 100644 index 000000000..630e6b594 --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/resources/init.sql @@ -0,0 +1,36 @@ +create table sim_import_batches (id bigserial primary key, + status text, + endedAt bigint, + importer text, + size integer, + hlrId bigserial, + profileVendorId bigserial); +create table sim_entries (id bigserial primary key, + profileVendorId bigserial, + hlrId bigserial, + msisdn text, + eid text, + profile text, + hlrState text, + smdpPlusState text, + provisionState text, + matchingId text, + batch bigserial, + imsi varchar(16), + iccid varchar(22), + pin1 varchar(4), + pin2 varchar(4), + puk1 varchar(8), + puk2 varchar(8), + UNIQUE (imsi), + UNIQUE (iccid)); +create table hlr_adapters (id bigserial primary key, + name text, + UNIQUE (name)); +create table profile_vendor_adapters (id bigserial primary key, + name text, + UNIQUE (name)); +create table sim_vendors_permitted_hlrs (id bigserial primary key, + profileVendorId bigserial, + hlrId bigserial, + UNIQUE (profileVendorId, hlrId)); diff --git a/sim-administration/simmanager/src/integration-test/resources/sample-sim-batch.csv b/sim-administration/simmanager/src/integration-test/resources/sample-sim-batch.csv new file mode 100644 index 000000000..518689ffd --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/resources/sample-sim-batch.csv @@ -0,0 +1,101 @@ +ICCID,IMSI,MSISDN,PIN1,PIN2,PUK1,PUK2,PROFILE +8901000000000000001,3101500000000000,4790900700,,,,,IPHONE_PROFILE_2 +8901000000000000019,3101500000000001,4790900701,,,,,IPHONE_PROFILE_2 +8901000000000000027,3101500000000002,4790900702,,,,,IPHONE_PROFILE_2 +8901000000000000035,3101500000000003,4790900703,,,,,IPHONE_PROFILE_2 +8901000000000000043,3101500000000004,4790900704,,,,,IPHONE_PROFILE_2 +8901000000000000050,3101500000000005,4790900705,,,,,IPHONE_PROFILE_2 +8901000000000000068,3101500000000006,4790900706,,,,,IPHONE_PROFILE_2 +8901000000000000076,3101500000000007,4790900707,,,,,IPHONE_PROFILE_2 +8901000000000000084,3101500000000008,4790900708,,,,,IPHONE_PROFILE_2 +8901000000000000092,3101500000000009,4790900709,,,,,IPHONE_PROFILE_2 +8901000000000000100,3101500000000010,4790900710,,,,,IPHONE_PROFILE_2 +8901000000000000118,3101500000000011,4790900711,,,,,IPHONE_PROFILE_2 +8901000000000000126,3101500000000012,4790900712,,,,,IPHONE_PROFILE_2 +8901000000000000134,3101500000000013,4790900713,,,,,IPHONE_PROFILE_2 +8901000000000000142,3101500000000014,4790900714,,,,,IPHONE_PROFILE_2 +8901000000000000159,3101500000000015,4790900715,,,,,IPHONE_PROFILE_2 +8901000000000000167,3101500000000016,4790900716,,,,,IPHONE_PROFILE_2 +8901000000000000175,3101500000000017,4790900717,,,,,IPHONE_PROFILE_2 +8901000000000000183,3101500000000018,4790900718,,,,,IPHONE_PROFILE_2 +8901000000000000191,3101500000000019,4790900719,,,,,IPHONE_PROFILE_2 +8901000000000000209,3101500000000020,4790900720,,,,,IPHONE_PROFILE_2 +8901000000000000217,3101500000000021,4790900721,,,,,IPHONE_PROFILE_2 +8901000000000000225,3101500000000022,4790900722,,,,,IPHONE_PROFILE_2 +8901000000000000233,3101500000000023,4790900723,,,,,IPHONE_PROFILE_2 +8901000000000000241,3101500000000024,4790900724,,,,,IPHONE_PROFILE_2 +8901000000000000258,3101500000000025,4790900725,,,,,IPHONE_PROFILE_2 +8901000000000000266,3101500000000026,4790900726,,,,,IPHONE_PROFILE_2 +8901000000000000274,3101500000000027,4790900727,,,,,IPHONE_PROFILE_2 +8901000000000000282,3101500000000028,4790900728,,,,,IPHONE_PROFILE_2 +8901000000000000290,3101500000000029,4790900729,,,,,IPHONE_PROFILE_2 +8901000000000000308,3101500000000030,4790900730,,,,,IPHONE_PROFILE_2 +8901000000000000316,3101500000000031,4790900731,,,,,IPHONE_PROFILE_2 +8901000000000000324,3101500000000032,4790900732,,,,,IPHONE_PROFILE_2 +8901000000000000332,3101500000000033,4790900733,,,,,IPHONE_PROFILE_2 +8901000000000000340,3101500000000034,4790900734,,,,,IPHONE_PROFILE_2 +8901000000000000357,3101500000000035,4790900735,,,,,IPHONE_PROFILE_2 +8901000000000000365,3101500000000036,4790900736,,,,,IPHONE_PROFILE_2 +8901000000000000373,3101500000000037,4790900737,,,,,IPHONE_PROFILE_2 +8901000000000000381,3101500000000038,4790900738,,,,,IPHONE_PROFILE_2 +8901000000000000399,3101500000000039,4790900739,,,,,IPHONE_PROFILE_2 +8901000000000000407,3101500000000040,4790900740,,,,,IPHONE_PROFILE_2 +8901000000000000415,3101500000000041,4790900741,,,,,IPHONE_PROFILE_2 +8901000000000000423,3101500000000042,4790900742,,,,,IPHONE_PROFILE_2 +8901000000000000431,3101500000000043,4790900743,,,,,IPHONE_PROFILE_2 +8901000000000000449,3101500000000044,4790900744,,,,,IPHONE_PROFILE_2 +8901000000000000456,3101500000000045,4790900745,,,,,IPHONE_PROFILE_2 +8901000000000000464,3101500000000046,4790900746,,,,,IPHONE_PROFILE_2 +8901000000000000472,3101500000000047,4790900747,,,,,IPHONE_PROFILE_2 +8901000000000000480,3101500000000048,4790900748,,,,,IPHONE_PROFILE_2 +8901000000000000498,3101500000000049,4790900749,,,,,IPHONE_PROFILE_2 +8901000000000000506,3101500000000050,4790900750,,,,,IPHONE_PROFILE_2 +8901000000000000514,3101500000000051,4790900751,,,,,IPHONE_PROFILE_2 +8901000000000000522,3101500000000052,4790900752,,,,,IPHONE_PROFILE_2 +8901000000000000530,3101500000000053,4790900753,,,,,IPHONE_PROFILE_2 +8901000000000000548,3101500000000054,4790900754,,,,,IPHONE_PROFILE_2 +8901000000000000555,3101500000000055,4790900755,,,,,IPHONE_PROFILE_2 +8901000000000000563,3101500000000056,4790900756,,,,,IPHONE_PROFILE_2 +8901000000000000571,3101500000000057,4790900757,,,,,IPHONE_PROFILE_2 +8901000000000000589,3101500000000058,4790900758,,,,,IPHONE_PROFILE_2 +8901000000000000597,3101500000000059,4790900759,,,,,IPHONE_PROFILE_2 +8901000000000000605,3101500000000060,4790900760,,,,,IPHONE_PROFILE_2 +8901000000000000613,3101500000000061,4790900761,,,,,IPHONE_PROFILE_2 +8901000000000000621,3101500000000062,4790900762,,,,,IPHONE_PROFILE_2 +8901000000000000639,3101500000000063,4790900763,,,,,IPHONE_PROFILE_2 +8901000000000000647,3101500000000064,4790900764,,,,,IPHONE_PROFILE_2 +8901000000000000654,3101500000000065,4790900765,,,,,IPHONE_PROFILE_2 +8901000000000000662,3101500000000066,4790900766,,,,,IPHONE_PROFILE_2 +8901000000000000670,3101500000000067,4790900767,,,,,IPHONE_PROFILE_2 +8901000000000000688,3101500000000068,4790900768,,,,,IPHONE_PROFILE_2 +8901000000000000696,3101500000000069,4790900769,,,,,IPHONE_PROFILE_2 +8901000000000000704,3101500000000070,4790900770,,,,,IPHONE_PROFILE_2 +8901000000000000712,3101500000000071,4790900771,,,,,IPHONE_PROFILE_2 +8901000000000000720,3101500000000072,4790900772,,,,,IPHONE_PROFILE_2 +8901000000000000738,3101500000000073,4790900773,,,,,IPHONE_PROFILE_2 +8901000000000000746,3101500000000074,4790900774,,,,,IPHONE_PROFILE_2 +8901000000000000753,3101500000000075,4790900775,,,,,IPHONE_PROFILE_2 +8901000000000000761,3101500000000076,4790900776,,,,,IPHONE_PROFILE_2 +8901000000000000779,3101500000000077,4790900777,,,,,IPHONE_PROFILE_2 +8901000000000000787,3101500000000078,4790900778,,,,,IPHONE_PROFILE_2 +8901000000000000795,3101500000000079,4790900779,,,,,IPHONE_PROFILE_2 +8901000000000000803,3101500000000080,4790900780,,,,,IPHONE_PROFILE_2 +8901000000000000811,3101500000000081,4790900781,,,,,IPHONE_PROFILE_2 +8901000000000000829,3101500000000082,4790900782,,,,,IPHONE_PROFILE_2 +8901000000000000837,3101500000000083,4790900783,,,,,IPHONE_PROFILE_2 +8901000000000000845,3101500000000084,4790900784,,,,,IPHONE_PROFILE_2 +8901000000000000852,3101500000000085,4790900785,,,,,IPHONE_PROFILE_2 +8901000000000000860,3101500000000086,4790900786,,,,,IPHONE_PROFILE_2 +8901000000000000878,3101500000000087,4790900787,,,,,IPHONE_PROFILE_2 +8901000000000000886,3101500000000088,4790900788,,,,,IPHONE_PROFILE_2 +8901000000000000894,3101500000000089,4790900789,,,,,IPHONE_PROFILE_2 +8901000000000000902,3101500000000090,4790900790,,,,,IPHONE_PROFILE_2 +8901000000000000910,3101500000000091,4790900791,,,,,IPHONE_PROFILE_2 +8901000000000000928,3101500000000092,4790900792,,,,,IPHONE_PROFILE_2 +8901000000000000936,3101500000000093,4790900793,,,,,IPHONE_PROFILE_2 +8901000000000000944,3101500000000094,4790900794,,,,,IPHONE_PROFILE_2 +8901000000000000951,3101500000000095,4790900795,,,,,IPHONE_PROFILE_2 +8901000000000000969,3101500000000096,4790900796,,,,,IPHONE_PROFILE_2 +8901000000000000977,3101500000000097,4790900797,,,,,IPHONE_PROFILE_2 +8901000000000000985,3101500000000098,4790900798,,,,,IPHONE_PROFILE_2 +8901000000000000993,3101500000000099,4790900799,,,,,IPHONE_PROFILE_2 diff --git a/sim-administration/simmanager/src/integration-test/resources/sim-manager.yaml b/sim-administration/simmanager/src/integration-test/resources/sim-manager.yaml new file mode 100644 index 000000000..2257522f7 --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/resources/sim-manager.yaml @@ -0,0 +1,45 @@ +logging: + level: INFO + +openApi: + name: SIM admin + description: SIM Administration Service + termsOfService: http://example.org + contactEmail: dev@redotter.sg + resourcePackage: org.ostelco + +hlrs: + - name: Foo + endpoint: http://localhost:9180/default/provision + userId: user + apiKey: xyz + +profileVendors: + - name: Bar + es2plusEndpoint: http://localhost:9080/gsma/rsp2/es2plus + requesterIdentifier: 123 + es9plusEndpoint: http://localhost:9080/notrealjustfortest/ + +# Note, list must end with a wildcard match +phoneTypes: + - regex: "android.*" + profile: ANDROID_PROFILE_1 + - regex: "iphone.*" + profile: IPHONE_PROFILE_1 + - regex: ".*" + profile: IPHONE_PROFILE_2 + +server: + type: default + applicationConnectors: + - type: http + port: 8090 + adminConnectors: + - type: http + port: 8091 + +database: + driverClass: org.postgresql.Driver + user: ${DB_USER:-test} + password: ${DB_PASSWORD:-test} + url: jdbc:postgresql://example.com/simmgr_test diff --git a/sim-administration/simmanager/src/integration-test/resources/sm-dp-plus.yaml b/sim-administration/simmanager/src/integration-test/resources/sm-dp-plus.yaml new file mode 100644 index 000000000..863ff796f --- /dev/null +++ b/sim-administration/simmanager/src/integration-test/resources/sm-dp-plus.yaml @@ -0,0 +1,30 @@ +logging: + level: INFO + +# Workaround for ensuring correct path to dataset to be loaded into +# the SM-DP+ emulator appl. on startup. +# The "correct" way would have been to set the path using: +# ConfigOverride.config("simBatchData", +# ResourceHelpers.resourceFilePath("sample-sim-batch.csv") +# when starting the SM-DP+ emulator in the test fixture, overriding +# the "simBatchData" config variable. Unfortunately Dropwizard will +# then set the "simBatchData" variable for all appl. that are started, +# causing start of the "sim-manager" appl. to fail as that appl. don't +# know (or use) this variable. +simBatchData: src/integration-test/resources/sample-sim-batch.csv + +openApi: + name: SM-DP+ emulator + description: Test fixture simulating ES2+ interactions of a SM-DP+ + termsOfService: http://example.org + contactEmail: dev@redotter.sg + resourcePackage: org.ostelco + +server: + type: default + applicationConnectors: + - type: http + port: 9080 + adminConnectors: + - type: http + port: 9081 diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/ArrowUtils.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/ArrowUtils.kt new file mode 100644 index 000000000..e1a375a6b --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/ArrowUtils.kt @@ -0,0 +1,10 @@ +package org.ostelco.simcards.admin + +import arrow.core.Either + + +/** + * TODO: 1. Document this method + * 2. Move it into a convenience libary for Arrow utilities. + */ +fun Either.mapRight(f:(R)->R2): Either = this.map(f) \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/PeriodicProvisioningTask.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/PeriodicProvisioningTask.kt new file mode 100644 index 000000000..64649b0a1 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/PeriodicProvisioningTask.kt @@ -0,0 +1,135 @@ +package org.ostelco.simcards.admin + +import arrow.core.Either +import arrow.core.fix +import arrow.core.left +import arrow.effects.IO +import arrow.instances.either.monad.flatMap +import arrow.instances.either.monad.monad +import com.google.common.collect.ImmutableMultimap +import io.dropwizard.servlets.tasks.Task +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.prime.getLogger +import org.ostelco.prime.simmanager.DatabaseError +import org.ostelco.prime.simmanager.NotFoundError +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.simcards.hss.HssEntry +import org.ostelco.simcards.hss.SimManagerToHssDispatcherAdapter +import org.ostelco.simcards.inventory.HssState +import org.ostelco.simcards.inventory.ProvisionState +import org.ostelco.simcards.inventory.SimEntry +import org.ostelco.simcards.inventory.SimInventoryDAO +import org.ostelco.simcards.inventory.SimProfileKeyStatistics +import java.io.PrintWriter + + +/** + * A dropwizard "task" that is intended to be invoked as an administrative step + * by an external agent that is part of the serving system, not a customer of it. + * + * The task implements pre-allocation of profiles in both HLR and SM-DP+ so that + * there will be a number of profiles available for quick allocation to customers + * without having to synchronously wait for a profile to be provisioned by these + * two. + */ + +class PreallocateProfilesTask( + private val lowWaterMark: Int = 10, + val maxNoOfProfileToAllocate: Int = 30, + val simInventoryDAO: SimInventoryDAO, + val httpClient: CloseableHttpClient, + val hssAdapterProxy: SimManagerToHssDispatcherAdapter, + val profileVendors: List) : Task("preallocate_sim_profiles") { + + private val logger by getLogger() + + @Throws(Exception::class) + override fun execute(parameters: ImmutableMultimap, output: PrintWriter) { + preAllocateSimProfiles() + } + + private fun preProvisionSimProfile(hssEntry: HssEntry, + simEntry: SimEntry): Either = + simInventoryDAO.getProfileVendorAdapterById(simEntry.profileVendorId) + .flatMap { profileVendorAdapter -> + + val profileVendorConfig: ProfileVendorConfig? = profileVendors.firstOrNull { + it.name == profileVendorAdapter.name + } + + if (profileVendorConfig != null) { + profileVendorAdapter.activate(httpClient = httpClient, + config = profileVendorConfig, + dao = simInventoryDAO, + simEntry = simEntry) + .flatMap { + hssAdapterProxy.activate(simEntry) + simInventoryDAO.setHssState(simEntry.id!!, HssState.ACTIVATED) + } + } else { + NotFoundError("Failed to find configuration for SIM profile vendor ${profileVendorAdapter.name} " + + "and HLR ${hssEntry.name}") + .left() + } + } + + private fun batchPreprovisionSimProfiles(hssEntry: HssEntry, + simProfileName: String, + profileStats: SimProfileKeyStatistics) { + val noOfProfilesToActuallyAllocate = + Math.min(maxNoOfProfileToAllocate.toLong(), profileStats.noOfUnallocatedEntries) + + for (i in 1..noOfProfilesToActuallyAllocate) { + + // XXX This is all well, if allocation doesn't fail, but if it fails, it will try the + // same profile forever, so something else should be done e.g. setting the + // state of the profile to "provision failed" or something of that nature, so that it + // is possible to move along. This is an error in the logic of this code. + simInventoryDAO.findNextNonProvisionedSimProfileForHss(hssId = hssEntry.id, profile = simProfileName) + .flatMap { simEntry -> + if (simEntry.id == null) { + DatabaseError("This should never happen, since everything that is read from a database should have an ID") + .left() + } else { + preProvisionSimProfile(hssEntry, simEntry) + .mapLeft { + logger.error("Preallocation of SIM ICCID {} failed with error: {}}", + simEntry.iccid, it.description) + simInventoryDAO.setProvisionState(simEntry.id, ProvisionState.ALLOCATION_FAILED) + } + } + } + } + } + + /** + * Made public to be testable. Perform + * allocation of profiles so that if possible, there will be tasks available for + * provisioning. + */ + public fun preAllocateSimProfiles() { + IO { + Either.monad().binding { + val hssEntries: Collection = simInventoryDAO.getHssEntries() + .bind() + + hssEntries.forEach{hssEntry -> + val simProfileNames: Collection = simInventoryDAO.getProfileNamesForHssById(hssEntry.id) + .bind() + for (simProfileName in simProfileNames) { + val profileStats = simInventoryDAO.getProfileStats(hssEntry.id, simProfileName) + .bind() + + if (profileStats.noOfEntriesAvailableForImmediateUse < lowWaterMark) { + logger.info("Preallocating new SIM batch with HLR {} and with profile {}", + hssEntry.name, simProfileName) + + batchPreprovisionSimProfiles(hssEntry = hssEntry, simProfileName = simProfileName, profileStats = profileStats) + } + } + } + }.fix() + }.unsafeRunSync() + } +} + diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimAdministrationConfiguration.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimAdministrationConfiguration.kt new file mode 100644 index 000000000..0352bd783 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimAdministrationConfiguration.kt @@ -0,0 +1,136 @@ +package org.ostelco.simcards.admin + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Configuration +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.db.DataSourceFactory +import org.ostelco.dropwizardutils.OpenapiResourceAdderConfig +import javax.validation.Valid +import javax.validation.constraints.NotNull + + +class SimAdministrationConfiguration : Configuration() { + @Valid + @NotNull + @JsonProperty("database") + val database: DataSourceFactory = DataSourceFactory() + + @Valid + @NotNull + @JsonProperty + val httpClient = HttpClientConfiguration() + + @Valid + @NotNull + @JsonProperty("openApi") + val openApi = OpenapiResourceAdderConfig() + + @Valid + @NotNull + @JsonProperty("profileVendors") + lateinit var profileVendors: List + + + @Valid + @JsonProperty("hssAdapter") + var hssAdapter: HssAdapterConfig? = null + + // XXX Make this optional once the hssAdapter mechanism + // has been made operational and stable. + @Valid + @NotNull + @JsonProperty("hlrs") + lateinit var hssVendors: List + + @Valid + @NotNull + @JsonProperty("phoneTypes") + lateinit var phoneTypes: List + + /* XXX Ideally the regex should be buildt when the config file is loaded, + not when it is used. */ + + /** + * Get profile based on given phone type/getProfileForPhoneType. + * @param name phone type/getProfileForPhoneType + * @return profile name + */ + fun getProfileForPhoneType(name: String) = phoneTypes.filter { name.matches(it.regex.toRegex(RegexOption.IGNORE_CASE)) } + .map { it.profile } + .first() +} + + + +class HssAdapterConfig { + + @Valid + @JsonProperty("hostname") + lateinit var hostname: String + + @Valid + @JsonProperty("port") + var port: Int = 0 +} + +class HssConfig { + + @Valid + // TODO: Make not null asap @NotNull + @JsonProperty("type") + lateinit var type: String + + @Valid + @NotNull + @JsonProperty("name") + lateinit var name: String + + @Valid + @NotNull + @JsonProperty("endpoint") + lateinit var endpoint: String + + @Valid + @NotNull + @JsonProperty("userId") + lateinit var userId: String + + @Valid + @NotNull + @JsonProperty("apiKey") + lateinit var apiKey: String +} + +class ProfileVendorConfig { + @Valid + @NotNull + @JsonProperty("name") + lateinit var name: String + + @Valid + @NotNull + @JsonProperty("es2plusEndpoint") + lateinit var es2plusEndpoint: String + + @Valid + @NotNull + @JsonProperty("requesterIdentifier") + lateinit var requesterIndentifier: String + + @Valid + @NotNull + @JsonProperty("es9plusEndpoint") + lateinit var es9plusEndpoint: String +} + +class PhoneTypeConfig { + @Valid + @NotNull + @JsonProperty("regex") + lateinit var regex: String + + @Valid + @NotNull + @JsonProperty("profile") + lateinit var profile: String +} \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimAdministrationModule.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimAdministrationModule.kt new file mode 100644 index 000000000..2d684f83a --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimAdministrationModule.kt @@ -0,0 +1,150 @@ +package org.ostelco.simcards.admin + +import com.codahale.metrics.health.HealthCheck +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonTypeName +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.jdbi3.JdbiFactory +import io.dropwizard.setup.Environment +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.dropwizardutils.OpenapiResourceAdder +import org.ostelco.prime.model.SimProfileStatus +import org.ostelco.prime.module.PrimeModule +import org.ostelco.sim.es2plus.ES2PlusIncomingHeadersFilter +import org.ostelco.sim.es2plus.SmDpPlusCallbackResource +import org.ostelco.simcards.admin.ApiRegistry.simInventoryApi +import org.ostelco.simcards.admin.ConfigRegistry.config +import org.ostelco.simcards.admin.ResourceRegistry.simInventoryResource +import org.ostelco.simcards.hss.DirectHssDispatcher +import org.ostelco.simcards.hss.HealthCheckRegistrar +import org.ostelco.simcards.hss.HssDispatcher +import org.ostelco.simcards.hss.HssGrpcAdapter +import org.ostelco.simcards.hss.SimManagerToHssDispatcherAdapter +import org.ostelco.simcards.hss.SimpleHssDispatcher +import org.ostelco.simcards.inventory.SimInventoryApi +import org.ostelco.simcards.inventory.SimInventoryCallbackService +import org.ostelco.simcards.inventory.SimInventoryDAO +import org.ostelco.simcards.inventory.SimInventoryDB +import org.ostelco.simcards.inventory.SimInventoryDBWrapperImpl +import org.ostelco.simcards.inventory.SimInventoryResource + +/** + * The SIM manager + * is an component that inputs inhales SIM batches + * from SIM profile factories (physical or esim). It then facilitates + * activation of SIM profiles to MSISDNs. A typical interaction is + * "find me a sim profile for this MSISDN for this HLR" , and then + * "activate that profile". The activation will typically involve + * at least talking to a HLR to permit user equipment to use the + * SIM profile to authenticate, and possibly also an SM-DP+ to + * activate a SIM profile (via its ICCID and possible an EID). + * The inventory can then serve as an intermidiary between the + * rest of the BSS and the OSS in the form of HSS and SM-DP+. + */ +@JsonTypeName("sim-manager") +class SimAdministrationModule : PrimeModule { + + private lateinit var DAO: SimInventoryDAO + + @JsonProperty("config") + fun setConfig(config: SimAdministrationConfiguration) { + ConfigRegistry.config = config + } + + fun getDAO() = DAO + + override fun init(env: Environment) { + val factory = JdbiFactory() + val jdbi = factory.build(env, + config.database, "postgresql") + .installPlugins() + DAO = SimInventoryDAO(SimInventoryDBWrapperImpl(jdbi.onDemand(SimInventoryDB::class.java))) + + val profileVendorCallbackHandler = SimInventoryCallbackService(DAO) + + val httpClient = HttpClientBuilder(env) + .using(config.httpClient) + .build("SIM inventory") + val jerseyEnv = env.jersey() + + OpenapiResourceAdder.addOpenapiResourceToJerseyEnv(jerseyEnv, config.openApi) + ES2PlusIncomingHeadersFilter.addEs2PlusDefaultFiltersAndInterceptors(jerseyEnv) + + /* Create the SIM manager API. */ + simInventoryApi = SimInventoryApi(httpClient, config, DAO) + + /* Add REST frontend. */ + simInventoryResource = SimInventoryResource(simInventoryApi) + jerseyEnv.register(simInventoryResource) + jerseyEnv.register(SmDpPlusCallbackResource(profileVendorCallbackHandler)) + + val dispatcher = makeHssDispatcher( + hssAdapterConfig = config.hssAdapter, + hssVendorConfigs = config.hssVendors, + httpClient = httpClient, + healthCheckRegistrar = object : HealthCheckRegistrar { + override fun registerHealthCheck(name: String, healthCheck: HealthCheck) { + env.healthChecks().register(name, healthCheck) + } + }) + + var hssAdapters = SimManagerToHssDispatcherAdapter( + dispatcher = dispatcher, + simInventoryDAO = this.DAO + ) + + env.admin().addTask(PreallocateProfilesTask( + simInventoryDAO = this.DAO, + httpClient = httpClient, + hssAdapterProxy = hssAdapters, + profileVendors = config.profileVendors)); + } + + + // XXX Implement a feature-flag so that when we want to switch from built in + // direct access to HSSes, to adapter-mediated access, we can do that easily + // via config. + private fun makeHssDispatcher( + hssAdapterConfig: HssAdapterConfig?, + hssVendorConfigs: List, + httpClient: CloseableHttpClient, + healthCheckRegistrar: HealthCheckRegistrar): HssDispatcher { + + if (hssAdapterConfig != null) { + return HssGrpcAdapter( + host = hssAdapterConfig.hostname, + port = hssAdapterConfig.port) + } else if (hssVendorConfigs != null) { + + val dispatchers = mutableSetOf() + + for (config in config.hssVendors) { + dispatchers.add( + SimpleHssDispatcher( + name = config.name, + httpClient = httpClient, + config = config)) + } + + return DirectHssDispatcher( + hssConfigs = config.hssVendors, + httpClient = httpClient, + healthCheckRegistrar = healthCheckRegistrar) + } else { + throw RuntimeException("Unable to find HSS adapter config, please check config") + } + } +} + +object ConfigRegistry { + lateinit var config: SimAdministrationConfiguration +} + +object ResourceRegistry { + lateinit var simInventoryResource: SimInventoryResource +} + +object ApiRegistry { + lateinit var simInventoryApi: SimInventoryApi + var simProfileStatusUpdateCallback: ((iccId: String, status: SimProfileStatus) -> Unit)? = null +} \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimManager.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimManager.kt new file mode 100644 index 000000000..ed3ad4995 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/admin/SimManager.kt @@ -0,0 +1,59 @@ +package org.ostelco.simcards.admin + +import arrow.core.Either +import org.ostelco.prime.getLogger +import org.ostelco.prime.model.SimEntry +import org.ostelco.prime.model.SimProfileStatus +import org.ostelco.prime.sim.SimManager +import org.ostelco.simcards.admin.ApiRegistry.simInventoryApi +import org.ostelco.simcards.admin.ApiRegistry.simProfileStatusUpdateCallback +import org.ostelco.simcards.inventory.SmDpPlusState + +class ESimManager : SimManager by SimManagerSingleton + +object SimManagerSingleton : SimManager { + + private val logger by getLogger() + + override fun allocateNextEsimProfile(hlr: String, phoneType: String?): Either = + simInventoryApi.allocateNextEsimProfile(hlrName = hlr, phoneType = phoneType ?: "iphone").bimap( + { + "Failed to allocate eSIM for HLR - $hlr for phoneType - $phoneType" + }, + { simEntry -> mapToModelSimEntry(simEntry) }) + + override fun getSimProfile(hlr: String, iccId: String): Either { + return simInventoryApi.findSimProfileByIccid(hlrName = hlr, iccid = iccId) + .map { simEntry -> mapToModelSimEntry(simEntry) } + .mapLeft { + logger.error("Failed to get SIM Profile Status", it.error) + it.description + } + } + + override fun getSimProfileStatusUpdates(onUpdate: (iccId: String, status: SimProfileStatus) -> Unit) { + simProfileStatusUpdateCallback = onUpdate + } + + private fun mapToModelSimEntry(simEntry: org.ostelco.simcards.inventory.SimEntry) : SimEntry { + + val status = asSimProfileStatus(simEntry.smdpPlusState) + return SimEntry( + iccId = simEntry.iccid, + status = status, + eSimActivationCode = simEntry.code ?: "", + msisdnList = listOf(simEntry.msisdn)) + } + + fun asSimProfileStatus(smdpPlusState: SmDpPlusState) : SimProfileStatus { + return when (smdpPlusState) { + SmDpPlusState.AVAILABLE -> SimProfileStatus.NOT_READY + SmDpPlusState.ALLOCATED -> SimProfileStatus.NOT_READY + SmDpPlusState.CONFIRMED -> SimProfileStatus.NOT_READY + SmDpPlusState.RELEASED -> SimProfileStatus.AVAILABLE_FOR_DOWNLOAD + SmDpPlusState.DOWNLOADED -> SimProfileStatus.DOWNLOADED + SmDpPlusState.INSTALLED -> SimProfileStatus.INSTALLED + SmDpPlusState.ENABLED -> SimProfileStatus.ENABLED + } + } +} \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/HssAdapter.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/HssAdapter.kt new file mode 100644 index 000000000..7f4bf98a3 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/HssAdapter.kt @@ -0,0 +1,237 @@ +package org.ostelco.simcards.hss + +import arrow.core.* +import com.codahale.metrics.health.HealthCheck +import io.grpc.ManagedChannelBuilder +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.prime.simmanager.AdapterError +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.simcards.admin.HssConfig +import org.ostelco.simcards.admin.mapRight +import org.ostelco.simcards.hss.profilevendors.api.HssServiceGrpc +import org.ostelco.simcards.hss.profilevendors.api.ServiceHealthQuery +import org.ostelco.simcards.inventory.HssState +import org.ostelco.simcards.inventory.SimEntry +import org.ostelco.simcards.inventory.SimInventoryDAO +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean + +interface HssDispatcher { + fun name(): String + fun iAmHealthy(): Boolean + fun activate(hssName: String, iccid: String, msisdn: String): Either + fun suspend(hssName: String, iccid: String): Either +} + + +class HssGrpcAdapter(private val host: String, private val port: Int) : HssDispatcher { + + + override fun name(): String { + return "HssGRPC Adapter connecting to host $host on port $port" + } + + private var blockingStub: HssServiceGrpc.HssServiceBlockingStub + + init { + val channel = + ManagedChannelBuilder.forAddress(host, port) + .usePlaintext(true) + .build() + + this.blockingStub = + HssServiceGrpc.newBlockingStub(channel) + } + + fun activateViaGrpc(hssName: String, iccid: String, msisdn: String): Boolean { + val activationRequest = + org.ostelco.simcards.hss.profilevendors.api.ActivationRequest.newBuilder() + .setIccid(iccid) + .setHss(hssName) + .setMsisdn(msisdn) + .build() + val response = blockingStub.activate(activationRequest) + return response.success + } + + + fun suspendViaGrpc(hssName: String, iccid: String): Boolean { + val suspensionRequest = org.ostelco.simcards.hss.profilevendors.api.SuspensionRequest.newBuilder() + .setIccid(iccid) + .setHss(hssName) + .build() + val response = blockingStub.suspend(suspensionRequest) + return response.success + } + + override fun iAmHealthy(): Boolean { + val request = ServiceHealthQuery.newBuilder().build() + val response = blockingStub.getHealthStatus(request) + return response.isHealthy + } + + + override fun activate(hssName: String, iccid: String, msisdn: String): Either { + if (activateViaGrpc(hssName = hssName, msisdn = msisdn, iccid = iccid)) { + return Right(Unit) + } else { + return Left(AdapterError("Could not activate via grpc (host=$host, port =$port) for hss = $hssName, msisdn=$msisdn, iccid=$iccid")) + } + + } + + override fun suspend(hssName: String, iccid: String): Either { + if (suspendViaGrpc(hssName = hssName, iccid = iccid)) { + return Right(Unit) + } else { + return Left(AdapterError("Could not activate via grpc (host=$host, port =$port) for hss = $hssName, iccid=$iccid")) + } + } +} + + +class DirectHssDispatcher( + val hssConfigs: List, + val httpClient: CloseableHttpClient, + val healthCheckRegistrar: HealthCheckRegistrar? = null) : HssDispatcher { + + override fun name(): String { + return "Direct HSS dispatcher serving HSS configurations with names: ${hssConfigs.map { it.name }}" + } + + val adapters = mutableSetOf() + + private val hssAdaptersByName = mutableMapOf() + private val healthchecks = mutableSetOf() + + init { + + for (config in hssConfigs) { + adapters.add(SimpleHssDispatcher(name = config.name, httpClient = httpClient, config = config)) + } + + + for (adapter in adapters) { + + val healthCheck = HssDispatcherHealthcheck(adapter.name(), adapter) + healthchecks.add(healthCheck) + + healthCheckRegistrar?.registerHealthCheck( + "HSS profilevendors for Hss named '${adapter.name()}'", + healthCheck) + + hssAdaptersByName[adapter.name()] = adapter + } + } + + // NOTE! Assumes that healthchecks on private hss entries are being run + // periodically and can therefore be considered to be updated & valid. + override fun iAmHealthy(): Boolean { + return healthchecks + .map { it.getLastHealthStatus() } + .reduce { a, b -> a && b } + } + + private fun getHssAdapterByName(name: String): HssDispatcher { + if (!hssAdaptersByName.containsKey(name)) { + throw RuntimeException("Unknown hss vendor name ? '$name'") + } + return hssAdaptersByName[name]!! + } + + override fun activate(hssName: String, iccid: String, msisdn: String): Either { + return getHssAdapterByName(hssName).activate(hssName= hssName, iccid = iccid, msisdn = msisdn) + } + + override fun suspend(hssName: String, iccid: String): Either { + return getHssAdapterByName(hssName).suspend(hssName=hssName, iccid = iccid) + } +} + +/** + * Keep a set of HSS entries that can be used when + * provisioning SIM profiles in remote HSSes. + */ +class SimManagerToHssDispatcherAdapter( + val dispatcher: HssDispatcher, + val simInventoryDAO: SimInventoryDAO) { + + private val log = LoggerFactory.getLogger(javaClass) + + private val idToNameMap = mutableMapOf() + + private val lock = Object() + + init { + updateHssIdToNameMap() + } + + private fun fetchHssEntriesFromDatabase(): List { + val returnValue = mutableListOf() + val entries = simInventoryDAO.getHssEntries() + .mapLeft { err -> + log.error("No HSS entries to be found by the DAO.") + log.error(err.description) + } + .mapRight { returnValue.addAll(it) } + return returnValue + } + + private fun updateHssIdToNameMap() { + synchronized(lock) { + + val newHssEntries = + fetchHssEntriesFromDatabase() + .filter { !idToNameMap.containsValue(it.name) } + + for (newHssEntry in newHssEntries) { + idToNameMap[newHssEntry.id] = newHssEntry.name + } + } + } + + + + fun activate(simEntry: SimEntry): Either { + synchronized(lock) { + return dispatcher.activate(hssName = idToNameMap[simEntry.hssId]!!, iccid = simEntry.iccid, msisdn = simEntry.msisdn) + .flatMap { simInventoryDAO.setHssState(simEntry.id!!, HssState.ACTIVATED) } + .flatMap { Unit.right() } + } + } + + fun suspend(simEntry: SimEntry): Either { + synchronized(lock) { + return dispatcher.suspend(hssName = idToNameMap[simEntry.hssId]!!, iccid = simEntry.iccid) + .flatMap { simInventoryDAO.setHssState(simEntry.id!!, HssState.NOT_ACTIVATED) } + .flatMap { Unit.right() } + } + } +} + +interface HealthCheckRegistrar { + fun registerHealthCheck(name: String, healthCheck: HealthCheck) +} + +class HssDispatcherHealthcheck( + private val name: String, + private val entry: HssDispatcher) : HealthCheck() { + + private val lastHealthStatus = AtomicBoolean(false) + + fun getLastHealthStatus(): Boolean { + return lastHealthStatus.get() + } + + @Throws(Exception::class) + override fun check(): Result { + return if (entry.iAmHealthy()) { + lastHealthStatus.set(true) + Result.healthy() + } else { + lastHealthStatus.set(false) + Result.unhealthy("HSS entry $name is not healthy") + } + } +} + diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/HssEntry.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/HssEntry.kt new file mode 100644 index 000000000..73f1d4ca2 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/HssEntry.kt @@ -0,0 +1,32 @@ +package org.ostelco.simcards.hss + + +import arrow.core.Either +import arrow.core.Left +import arrow.core.left +import arrow.core.right +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.http.client.methods.RequestBuilder +import org.apache.http.entity.StringEntity +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.prime.getLogger +import org.ostelco.prime.simmanager.AdapterError +import org.ostelco.prime.simmanager.NotUpdatedError +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.simcards.admin.HssConfig +import javax.ws.rs.core.MediaType + +/** + * This is a datum that is stored in a database. + * + * When a VLR asks the HLR for the an authentication triplet, then the + * HLR will know that it should give an answer. + * + * id - is a database internal identifier. + * name - is an unique instance of HLR reference. + */ +data class HssEntry( + @JsonProperty("id") val id: Long, + @JsonProperty("name") val name: String) + diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/SimpleHssDispatcher.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/SimpleHssDispatcher.kt new file mode 100644 index 000000000..8824a1141 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/hss/SimpleHssDispatcher.kt @@ -0,0 +1,149 @@ +package org.ostelco.simcards.hss + +import arrow.core.Either +import arrow.core.Left +import arrow.core.left +import arrow.core.right +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.http.client.methods.RequestBuilder +import org.apache.http.entity.StringEntity +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.prime.getLogger +import org.ostelco.prime.simmanager.AdapterError +import org.ostelco.prime.simmanager.NotUpdatedError +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.simcards.admin.HssConfig +import javax.ws.rs.core.MediaType + +/** + * An interface to a simple HSS REST based HSS. + */ +class SimpleHssDispatcher(val name: String, + val httpClient: CloseableHttpClient, + val config: HssConfig) : HssDispatcher { + + private val logger by getLogger() + + /* For payload serializing. */ + private val mapper = jacksonObjectMapper() + + override fun name() = name + + override fun iAmHealthy(): Boolean = true + + /** + * Requests the external HLR service to activate the SIM profile. + * @param simEntry SIM profile to activate + * @return Updated SIM Entry + */ + + override fun activate(hssName: String, iccid: String, msisdn: String): Either { + + // XXX Question: Is there a way to do the checks we see below in a method, and then + // if any of them fail, just return from this method using the Either method + // in a Exception-like behavior? I'm thinking someting along the lines of + // breakIfLeft(method(..)). Kind of an implicit monadization of the + // control flow. + + // Just a sanity check + if (hssName != name) { + return NotUpdatedError("Attempt to activate hssName=$hssName, iccid=$iccid, msisdn=$msisdn in a dispatcher for hss named $name").left() + } + + // Checking out the iccid value. + if (iccid.isEmpty()) { + return NotUpdatedError("Empty ICCID value in SIM activation request to hssName ${config.name}") + .left() + } + + if (!iccid.matches(Regex("^\\d{19,20}"))) { + return NotUpdatedError("Ill formatted ICCID $iccid").left() + } + + val body = mapOf( + "bssid" to config.name, + "iccid" to iccid, + "msisdn" to msisdn, + "userid" to config.userId + ) + + val payload = mapper.writeValueAsString(body) + + val request = RequestBuilder.post() + .setUri("${config.endpoint}/activate") + .setHeader("x-api-key", config.apiKey) + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + .setEntity(StringEntity(payload)) + .build() + + return try { + httpClient.execute(request).use { + when (it.statusLine.statusCode) { + 201 -> { + logger.info("HLR activation message to BSSID {} for ICCID {} completed OK", + config.name, + iccid).right() + } + else -> { + logger.warn("HLR activation message to BSSID {} for ICCID {} failed with status ({}) {}", + config.name, + iccid, + it.statusLine.statusCode, + it.statusLine.reasonPhrase) + NotUpdatedError("Failed to activate ICCID ${iccid} with BSSID ${config.name} (status-code: ${it.statusLine.statusCode})") + .left() + } + } + } + } catch (e: Exception) { + logger.error("HLR activation message to BSSID {} for ICCID {} failed with error: {}", + config.name, + iccid, + e) + AdapterError("HLR activation message to BSSID ${config.name} for ICCID ${iccid} failed with error: ${e}") + .left() + } + } + + /** + * Requests the external HLR service to deactivate the SIM profile. + * @param simEntry SIM profile to deactivate + * @return Updated SIM profile + */ + override fun suspend(hssName: String, iccid: String): Either { + + if (hssName != name) { + return Left(AdapterError("Attempt to suspend hssName=$hssName, iccid=$iccid in a dispatcher for hss named $name")) + } + + if (iccid.isEmpty()) { + return NotUpdatedError("Illegal parameter in SIM deactivation request to BSSID ${config.name}") + .left() + } + + val request = RequestBuilder.delete() + .setUri("${config.endpoint}/deactivate/${iccid}") + .setHeader("x-api-key", config.apiKey) + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + .build() + + return try { + httpClient.execute(request).use { + when (it.statusLine.statusCode) { + 200 -> { + logger.info("HLR deactivation message to HSS ${config.name} for ICCID ${iccid }completed OK").right() + } + else -> { + logger.warn("HLR deactivation message to HSS ${config.name} for ICCID ${iccid} failed with status (${it.statusLine.statusCode}) ${it.statusLine.reasonPhrase}") + NotUpdatedError("Failed to deactivate ICCID ${iccid} with BSSID ${config.name} (status-code: ${it.statusLine.statusCode}") + .left() + } + } + } + } catch (e: Exception) { + logger.error("HLR deactivation message to BSSID ${config.name} for ICCID ${iccid} failed with error: ${e}") + AdapterError("HLR deactivation message to BSSID ${config.name} for ICCID ${iccid} failed with error: ${e}") + .left() + } + } +} \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryApi.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryApi.kt new file mode 100644 index 000000000..5ac7b346e --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryApi.kt @@ -0,0 +1,139 @@ +package org.ostelco.simcards.inventory + +import arrow.core.Either +import arrow.core.fix +import arrow.core.left +import arrow.core.right +import arrow.effects.IO +import arrow.instances.either.monad.flatMap +import arrow.instances.either.monad.monad +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.prime.simmanager.NotFoundError +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.sim.es2plus.ProfileStatus +import org.ostelco.simcards.admin.ProfileVendorConfig +import org.ostelco.simcards.admin.SimAdministrationConfiguration +import org.ostelco.simcards.profilevendors.ProfileVendorAdapter +import java.io.InputStream + + +class SimInventoryApi(private val httpClient: CloseableHttpClient, + private val config: SimAdministrationConfiguration, + private val dao: SimInventoryDAO) { + + fun findSimProfileByIccid(hlrName: String, iccid: String): Either = + IO { + Either.monad().binding { + + val simEntry = dao.getSimProfileByIccid(iccid).bind() + + checkForValidHlr(hlrName, simEntry) + + val profileVendorAndConfig = getProfileVendorAdapterAndConfig(simEntry).bind() + + val config = profileVendorAndConfig.second + + simEntry.copy(code = "LPA:1\$${config.es9plusEndpoint}\$${simEntry.matchingId}") + + }.fix() + }.unsafeRunSync() + + fun findSimProfileByImsi(hlrName: String, imsi: String): Either = + dao.getSimProfileByImsi(imsi) + .flatMap { simEntry -> + checkForValidHlr(hlrName, simEntry) + } + + fun findSimProfileByMsisdn(hlrName: String, msisdn: String): Either = + dao.getSimProfileByMsisdn(msisdn) + .flatMap { simEntry -> + checkForValidHlr(hlrName, simEntry) + } + + fun getSimProfileStatus(hlrName: String, iccid: String): Either = + findSimProfileByIccid(hlrName, iccid) + .flatMap { simEntry -> + getProfileVendorAdapterAndConfig(simEntry) + .flatMap { + it.first.getProfileStatus(httpClient, it.second, iccid) + } + } + + + fun allocateNextEsimProfile(hlrName: String, phoneType: String): Either = + IO { + Either.monad().binding { + val hlrAdapter = dao.getHssEntryByName(hlrName) + .bind() + val profile = getProfileForPhoneType(phoneType) + .bind() + val simEntry = dao.findNextReadyToUseSimProfileForHss(hlrAdapter.id, profile) + .bind() + val profileVendorAndConfig = getProfileVendorAdapterAndConfig(simEntry) + .bind() + + val config = profileVendorAndConfig.second + + val updatedSimEntry = dao.setProvisionState(simEntry.id!!, ProvisionState.PROVISIONED) + .bind() + + /* Add 'code' field content. + Original format: LPA:: + New format: LPA:1$$ */ + updatedSimEntry.copy(code = "LPA:1\$${config.es9plusEndpoint}\$${updatedSimEntry.matchingId}") + }.fix() + }.unsafeRunSync() + + fun importBatch(hlrName: String, simVendor: String, csvInputStream: InputStream): Either = + IO { + Either.monad().binding { + val profileVendorAdapter = dao.getProfileVendorAdapterByName(simVendor) + .bind() + val hlrAdapter = dao.getHssEntryByName(hlrName) + .bind() + + /* Exits if not true. */ + dao.simVendorIsPermittedForHlr(profileVendorAdapter.id, hlrAdapter.id) + .bind() + dao.importSims(importer = "importer", // TODO: This is a very strange name for an importer .-) + hlrId = hlrAdapter.id, + profileVendorId = profileVendorAdapter.id, + csvInputStream = csvInputStream).bind() + }.fix() + }.unsafeRunSync() + + /* Helper functions. */ + + private fun checkForValidHlr(hlrName: String, simEntry: SimEntry): Either = + dao.getHssEntryById(simEntry.hssId) + .flatMap { hlrAdapter -> + if (hlrName != hlrAdapter.name) + NotFoundError("HLR name $hlrName does not match SIM profile HLR ${hlrAdapter.name}") + .left() + else + simEntry.right() + } + + + private fun getProfileVendorAdapterAndConfig(simEntry: SimEntry): Either> = + dao.getProfileVendorAdapterById(simEntry.profileVendorId) + .flatMap { profileVendorAdapter -> + val config: ProfileVendorConfig? = config.profileVendors.firstOrNull { + it.name == profileVendorAdapter.name + } + if (config != null) + Pair(profileVendorAdapter, config).right() + else + NotFoundError("Could not find configuration for SIM profile vendor ${profileVendorAdapter.name}") + .left() + } + + private fun getProfileForPhoneType(phoneType: String): Either { + val profile: String? = config.getProfileForPhoneType(phoneType) + return if (profile != null) + profile.right() + else + NotFoundError("Could not find configuration for phone type $phoneType") + .left() + } +} diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryCallbackService.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryCallbackService.kt new file mode 100644 index 000000000..cc9969745 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryCallbackService.kt @@ -0,0 +1,94 @@ +package org.ostelco.simcards.inventory + +import org.ostelco.prime.getLogger +import org.ostelco.sim.es2plus.ES2NotificationPointStatus +import org.ostelco.sim.es2plus.ES2RequestHeader +import org.ostelco.sim.es2plus.FunctionExecutionStatusType +import org.ostelco.sim.es2plus.SmDpPlusCallbackService +import org.ostelco.simcards.admin.ApiRegistry.simProfileStatusUpdateCallback +import org.ostelco.simcards.admin.SimManagerSingleton.asSimProfileStatus + +/** + * ES2+ callbacks handling. + */ +class SimInventoryCallbackService(val dao: SimInventoryDAO) : SmDpPlusCallbackService { + + private val logger by getLogger() + + override fun handleDownloadProgressInfo(header: ES2RequestHeader, + eid: String?, + iccid: String, + profileType: String, + timestamp: String, + notificationPointId: Int, + notificationPointStatus: ES2NotificationPointStatus, + resultData: String?, + imei: String?) { + if (notificationPointStatus.status == FunctionExecutionStatusType.ExecutedSuccess) { + + /* XXX To be removed or updated to debug. */ + logger.info("download-progress-info: Received message with status 'executed-success' for ICCID {}" + + "(notificationPointId: {}, profileType: {}, resultData: {})", + iccid, notificationPointId, profileType, resultData) + + /* Update EID. */ + if (!eid.isNullOrEmpty()) { + /* XXX To be removed or updated to debug. */ + logger.info("download-progress-info: Updating EID to {} for ICCID {}", + eid, iccid) + dao.setEidOfSimProfileByIccid(iccid, eid) + } + + /** + * Update SM-DP+ state. + * There is a somewhat more subtle failure mode, namly that the SM-DP+ for some reason + * is unable to signal back, in that case the state has actually changed, but that fact will not + * be picked up by the state as stored in the database, and if the user interface is dependent + * on that state, the user interface may suffer a failure. These issues needs to be gamed out + * and fixed in some reasonable manner. + */ + when (notificationPointId) { + 1 -> { + /* Eligibility and retry limit check. */ + } + 2 -> { + /* ConfirmationFailure. */ + } + 3 -> { + /* BPP download. */ + gotoState(iccid, SmDpPlusState.DOWNLOADED) + } + 4 -> { + /* BPP installation. */ + gotoState(iccid, SmDpPlusState.INSTALLED) + } + else -> { + /* Unexpected check point value. */ + logger.error("download-progress-info: Received message with unexpected 'notificationPointId' {} for ICCID {}" + + "(notificationPointStatus: {}, profileType: {}, resultData: {})", + notificationPointId, iccid, notificationPointStatus, + profileType, resultData) + } + } + } else { + /* XXX Update to handle other cases explicitly + review of logging. */ + logger.warn("download-progress-info: Received message with notificationPointStatus {} for ICCID {}" + + "(notificationPointId: {}, profileType: {}, resultData: {})", + notificationPointStatus, iccid, notificationPointId, + profileType, resultData) + } + } + + /** + * This is in fact buggy, since it assumes that the transitions are legal, which they only are + * they are carried out on profiles that are in the database, and that the transitions that are + * being performed are valid state transitions. None of these criteria are tested for, and + * errors are not si + */ + fun gotoState(iccid: String, targetSmdpPlusStatus: SmDpPlusState) { + logger.info("Updating SM-DP+ state to {} with value from 'download-progress-info' message' for ICCID {}", + SmDpPlusState.DOWNLOADED, iccid) + dao.setSmDpPlusStateUsingIccid(iccid, targetSmdpPlusStatus) + simProfileStatusUpdateCallback?.invoke(iccid, asSimProfileStatus(targetSmdpPlusStatus)) + } +} diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDAO.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDAO.kt new file mode 100644 index 000000000..7a2a832a5 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDAO.kt @@ -0,0 +1,312 @@ +package org.ostelco.simcards.inventory + +import arrow.core.Either +import arrow.core.fix +import arrow.core.flatMap +import arrow.effects.IO +import arrow.instances.either.monad.monad +import com.fasterxml.jackson.annotation.JsonProperty +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVParser +import org.jdbi.v3.core.mapper.RowMapper +import org.jdbi.v3.core.mapper.reflect.ColumnName +import org.jdbi.v3.core.statement.StatementContext +import org.jdbi.v3.sqlobject.customizer.Bind +import org.jdbi.v3.sqlobject.transaction.Transaction +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.simcards.hss.HssEntry +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.nio.charset.Charset +import java.sql.ResultSet +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.atomic.AtomicLong + + +enum class HssState { + NOT_ACTIVATED, + ACTIVATED, +} + +/* ES2+ interface description - GSMA states forward transition. */ +enum class SmDpPlusState { + /* ES2+ protocol - between SM-DP+ servcie and backend. */ + AVAILABLE, + ALLOCATED, + CONFIRMED, /* Not used as 'releaseFlag' is set to true in 'confirm-order' message. */ + RELEASED, + /* ES9+ protocol - between SM-DP+ service and handset. */ + DOWNLOADED, + INSTALLED, + ENABLED, + + +} + +enum class ProvisionState { + AVAILABLE, + PROVISIONED, /* The SIM profile has been taken into use (by a subscriber). */ + RESERVED, /* Reserved SIM profile (f.ex. used for testing). */ + ALLOCATION_FAILED +} + + +/** + * Representing a single SIM card. + */ +data class SimEntry( + @JsonProperty("id") val id: Long? = null, + @JsonProperty("batch") val batch: Long, + @ColumnName("hlrId") @JsonProperty("hssId") val hssId: Long, + @JsonProperty("profileVendorId") val profileVendorId: Long, + @JsonProperty("msisdn") val msisdn: String, + @JsonProperty("iccid") val iccid: String, + @JsonProperty("imsi") val imsi: String, + @JsonProperty("eid") val eid: String? = null, + @JsonProperty("profile") val profile: String, + @ColumnName("hlrState") @JsonProperty("hssState") val hssState: HssState = HssState.NOT_ACTIVATED, + @JsonProperty("smdpPlusState") val smdpPlusState: SmDpPlusState = SmDpPlusState.AVAILABLE, + @JsonProperty("provisionState") val provisionState: ProvisionState = ProvisionState.AVAILABLE, + @JsonProperty("matchingId") val matchingId: String? = null, + @JsonProperty("pin1") val pin1: String? = null, + @JsonProperty("pin2") val pin2: String? = null, + @JsonProperty("puk1") val puk1: String? = null, + @JsonProperty("puk2") val puk2: String? = null, + @JsonProperty("code") val code: String? = null +) + +/** + * Describe a batch of SIM cards that was imported at some time + */ +data class SimImportBatch( + @JsonProperty("id") val id: Long, + @JsonProperty("endedAt") val endedAt: Long, + @JsonProperty("message") val status: String?, + @JsonProperty("importer") val importer: String, + @JsonProperty("size") val size: Long, + @ColumnName("hlrId") @JsonProperty("hssId") val hssId: Long, + @JsonProperty("profileVendorId") val profileVendorId: Long +) + + +class SimEntryIterator(profileVendorId: Long, + hssId: Long, + batchId: Long, + csvInputStream: InputStream) : Iterator { + + var count = AtomicLong(0) + // TODO: The current implementation puts everything in a deque at startup. + // This is correct, but inefficient, in partricular for large + // batches. Once proven to work, this thing should be rewritten + // to use coroutines, to let the "next" get the next available + // sim entry. It may make sense to have a reader and writer thread + // coordinating via the deque. + private val values = ConcurrentLinkedDeque() + + init { + // XXX Adjust to fit whatever format we should cater to, there may + // be some variation between sim vendors, and that should be + // something we can adjust to given the parameters sent to the + // reader class on creation. Should be configurable in + // a config file or other config database. + + val csvFileFormat = CSVFormat.DEFAULT + .withQuote(null) + .withFirstRecordAsHeader() + .withIgnoreEmptyLines(true) + .withTrim() + .withIgnoreSurroundingSpaces() + .withNullString("") + .withDelimiter(',') + + BufferedReader(InputStreamReader(csvInputStream, Charset.forName( + "ISO-8859-1"))).use { reader -> + CSVParser(reader, csvFileFormat).use { csvParser -> + for (row in csvParser) { + val iccid = row.get("ICCID") + val imsi = row.get("IMSI") + val msisdn = row.get("MSISDN") + val pin1 = row?.get("PIN1") + val pin2 = row?.get("PIN2") + val puk1 = row?.get("PUK1") + val puk2 = row?.get("PUK2") + val profile = row.get("PROFILE") + + val value = SimEntry( + batch = batchId, + hssId = hssId, + profileVendorId = profileVendorId, + iccid = iccid, + imsi = imsi, + msisdn = msisdn, + pin1 = pin1, + puk1 = puk1, + puk2 = puk2, + pin2 = pin2, + profile = profile + ) + + values.add(value) + count.incrementAndGet() + } + } + } + } + + /** + * Returns the next element in the iteration. + */ + override operator fun next(): SimEntry { + return values.removeLast() + } + + /** + * Returns `true` if the iteration has more elements. + */ + override operator fun hasNext(): Boolean { + return !values.isEmpty() + } +} + +/** + * SIM DB DAO. + */ +class SimInventoryDAO(private val db: SimInventoryDBWrapperImpl) : SimInventoryDBWrapper by db { + + /** + * Check if the SIM vendor can be use for handling SIMs handled + * by the given HLR. + * @param profileVendorId SIM profile vendor to check + * @param hssId HLR to check + * @return true if permitted false otherwise + */ + fun simVendorIsPermittedForHlr(profileVendorId: Long, + hssId: Long): Either = + findSimVendorForHssPermissions(profileVendorId, hssId) + .flatMap { + Either.right(it.isNotEmpty()) + } + + /** + * Set permission for a SIM profile vendor to activate SIM profiles + * with a specific HLR. + * @param profileVendor name of SIM profile vendor + * @param hssName name of HLR + * @return true on successful update + */ + @Transaction + fun permitVendorForHssByNames(profileVendor: String, hssName: String): Either = + IO { + Either.monad().binding { + val profileVendorAdapter = getProfileVendorAdapterByName(profileVendor) + .bind() + val hlrAdapter = getHssEntryByName(hssName) + .bind() + + storeSimVendorForHssPermission(profileVendorAdapter.id, hlrAdapter.id) + .bind() > 0 + }.fix() + }.unsafeRunSync() + + // + // Importing + // + + override fun insertAll(entries: Iterator): Either = + db.insertAll(entries) + + @Transaction + fun importSims(importer: String, + hlrId: Long, + profileVendorId: Long, + csvInputStream: InputStream): Either = + IO { + Either.monad().binding { + createNewSimImportBatch(importer = importer, + hssId = hlrId, + profileVendorId = profileVendorId) + .bind() + val batchId = lastInsertedRowId() + .bind() + val values = SimEntryIterator(profileVendorId = profileVendorId, + hssId = hlrId, + batchId = batchId, + csvInputStream = csvInputStream) + insertAll(values) + .bind() + updateBatchState(id = batchId, + size = values.count.get(), + status = "SUCCESS", // TODO: Use enumeration, not naked string. + endedAt = System.currentTimeMillis()) + .bind() + getBatchInfo(batchId) + .bind() + }.fix() + }.unsafeRunSync() + + // + // Finding next free SIM card for a particular HLR. + // + + /** + * Get relevant statistics for a particular profile type for a particular HLR. + */ + fun getProfileStats(@Bind("hssId") hssId: Long, + @Bind("simProfile") simProfile: String): + Either = + IO { + Either.monad().binding { + + val keyValuePairs = mutableMapOf() + + getProfileStatsAsKeyValuePairs(hssId = hssId, simProfile = simProfile).bind() + .forEach { keyValuePairs.put(it.key, it.value) } + + val noOfEntries = keyValuePairs["NO_OF_ENTRIES"]!! + val noOfUnallocatedEntries = keyValuePairs["NO_OF_UNALLOCATED_ENTRIES"]!! + val noOfReleasedEntries = keyValuePairs["NO_OF_RELEASED_ENTRIES"]!! + val noOfEntriesAvailableForImmediateUse = keyValuePairs["NO_OF_ENTRIES_READY_FOR_IMMEDIATE_USE"]!! + + SimProfileKeyStatistics( + noOfEntries = noOfEntries, + noOfUnallocatedEntries = noOfUnallocatedEntries, + noOfEntriesAvailableForImmediateUse = noOfEntriesAvailableForImmediateUse, + noOfReleasedEntries = noOfReleasedEntries) + }.fix() + }.unsafeRunSync() +} + +class SimProfileKeyStatistics( + val noOfEntries: Long, + val noOfUnallocatedEntries: Long, + val noOfReleasedEntries: Long, + val noOfEntriesAvailableForImmediateUse: Long) + + +class KeyValueMapper : RowMapper { + + override fun map(row: ResultSet, ctx: StatementContext): KeyValuePair? { + if (row.isAfterLast) { + return null + } + + val value = row.getLong("VALUE") + val key = row.getString("KEY") + return KeyValuePair(key = key, value = value) + } +} + +data class KeyValuePair(val key: String, val value: Long) + +class HlrEntryMapper : RowMapper { + override fun map(row: ResultSet, ctx: StatementContext): HssEntry? { + if (row.isAfterLast) { + return null + } + + val id = row.getLong("id") + val name = row.getString("name") + return HssEntry(id = id, name = name) + } +} diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDB.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDB.kt new file mode 100644 index 000000000..ccdd8377f --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDB.kt @@ -0,0 +1,267 @@ +package org.ostelco.simcards.inventory + +import org.jdbi.v3.sqlobject.config.RegisterRowMapper +import org.jdbi.v3.sqlobject.customizer.BindBean +import org.jdbi.v3.sqlobject.statement.BatchChunkSize +import org.jdbi.v3.sqlobject.statement.SqlBatch +import org.jdbi.v3.sqlobject.statement.SqlQuery +import org.jdbi.v3.sqlobject.statement.SqlUpdate +import org.jdbi.v3.sqlobject.transaction.Transaction +import org.ostelco.simcards.hss.HssEntry +import org.ostelco.simcards.profilevendors.ProfileVendorAdapter + +/** + * Low-level SIM DB interface. + * Note: Postgresql specific SQL statements (I think). + */ +interface SimInventoryDB { + + @SqlQuery("""SELECT * FROM sim_entries + WHERE id = :id""") + fun getSimProfileById(id: Long): SimEntry? + + @SqlQuery("""SELECT * FROM sim_entries + WHERE iccid = :iccid""") + fun getSimProfileByIccid(iccid: String): SimEntry? + + @SqlQuery("""SELECT * FROM sim_entries + WHERE imsi = :imsi""") + fun getSimProfileByImsi(imsi: String): SimEntry? + + @SqlQuery("""SELECT * FROM sim_entries + WHERE msisdn = :msisdn""") + fun getSimProfileByMsisdn(msisdn: String): SimEntry? + + /* + * Find next available SIM card for a particular HLR ready + * to be 'provisioned' with the SM-DP+ and HLR vendors. + */ + @SqlQuery("""SELECT a.* + FROM sim_entries a + JOIN (SELECT id, + CASE + WHEN hlrstate = 'NOT_ACTIVATED' + AND smdpplusstate = 'AVAILABLE' THEN 1 + WHEN hlrstate = 'NOT_ACTIVATED' + AND smdpplusstate = 'RELEASED' THEN 2 + WHEN hlrstate = 'ACTIVATED' + AND smdpplusstate = 'AVAILABLE' THEN 3 + ELSE 9999 + END AS position + FROM sim_entries + WHERE provisionState = 'AVAILABLE' + AND hlrId = :hssId + AND profile = :profile + ORDER BY position ASC, + id ASC) b + ON ( a.id = b.id + AND b.position < 9999 ) + LIMIT 1""") + fun findNextNonProvisionedSimProfileForHss(hssId: Long, + profile: String): SimEntry? + + /* + * Find next ready to use SIM card for a particular HLR + * and profile (phone type). + */ + @SqlQuery("""SELECT * + FROM sim_entries + WHERE hlrState = 'ACTIVATED' + AND smdpplusstate = 'RELEASED' + AND provisionState = 'AVAILABLE' + AND hlrId = :hssId + AND profile = :profile + LIMIT 1""") + fun findNextReadyToUseSimProfileForHlr(hssId: Long, + profile: String): SimEntry? + + @SqlUpdate("""UPDATE sim_entries SET eid = :eid + WHERE iccid = :iccid""") + fun updateEidOfSimProfileByIccid(iccid: String, + eid: String): Int + + @SqlUpdate("""UPDATE sim_entries SET eid = :eid + WHERE id = :id""") + fun updateEidOfSimProfile(id: Long, + eid: String): Int + + /* + * State information. + */ + + @SqlUpdate("""UPDATE sim_entries SET hlrState = :hssState + WHERE id = :id""") + fun updateHlrState(id: Long, + hssState: HssState): Int + + @SqlUpdate("""UPDATE sim_entries SET provisionState = :provisionState + WHERE id = :id""") + fun updateProvisionState(id: Long, + provisionState: ProvisionState): Int + + @SqlUpdate("""UPDATE sim_entries SET hlrState = :hssState, + provisionState = :provisionState + WHERE id = :id""") + fun updateHlrStateAndProvisionState(id: Long, + hssState: HssState, + provisionState: ProvisionState): Int + + @SqlUpdate("""UPDATE sim_entries SET smdpPlusState = :smdpPlusState + WHERE id = :id""") + fun updateSmDpPlusState(id: Long, smdpPlusState: SmDpPlusState): Int + + @SqlUpdate("""UPDATE sim_entries SET smdpPlusState = :smdpPlusState + WHERE iccid = :iccid""") + fun updateSmDpPlusStateUsingIccid(iccid: String, smdpPlusState: SmDpPlusState): Int + + @SqlUpdate("""UPDATE sim_entries SET smdpPlusState = :smdpPlusState, + matchingId = :matchingId + WHERE id = :id""") + fun updateSmDpPlusStateAndMatchingId(id: Long, + smdpPlusState: SmDpPlusState, + matchingId: String): Int + + /* + * HLR and SM-DP+ 'adapters'. + */ + + @SqlQuery("""SELECT id FROM sim_vendors_permitted_hlrs + WHERE profileVendorId = profileVendorId + AND hlrId = :hssId""") + fun findSimVendorForHssPermissions(profileVendorId: Long, + hssId: Long): List + + @SqlUpdate("""INSERT INTO sim_vendors_permitted_hlrs + (profilevendorid, + hlrid) + SELECT :profileVendorId, + :hssId + WHERE NOT EXISTS (SELECT 1 + FROM sim_vendors_permitted_hlrs + WHERE profilevendorid = :profileVendorId + AND hlrid = :hssId)""") + fun storeSimVendorForHssPermission(profileVendorId: Long, + hssId: Long): Int + + @SqlUpdate("""INSERT INTO hlr_adapters + (name) + SELECT :name + WHERE NOT EXISTS (SELECT 1 + FROM hlr_adapters + WHERE name = :name)""") + fun addHssAdapter(name: String): Int + + @SqlQuery("""SELECT * FROM hlr_adapters + WHERE name = :name""") + fun getHssEntryByName(name: String): HssEntry + + @SqlQuery("""SELECT * FROM hlr_adapters + WHERE id = :id""") + fun getHssEntryById(id: Long): HssEntry + + @SqlUpdate("""INSERT INTO profile_vendor_adapters + (name) + SELECT :name + WHERE NOT EXISTS (SELECT 1 + FROM profile_vendor_adapters + WHERE name = :name) """) + fun addProfileVendorAdapter(name: String): Int + + @SqlQuery("""SELECT * FROM profile_vendor_adapters + WHERE name = :name""") + fun getProfileVendorAdapterByName(name: String): ProfileVendorAdapter? + + @SqlQuery("""SELECT * FROM profile_vendor_adapters + WHERE id = :id""") + fun getProfileVendorAdapterById(id: Long): ProfileVendorAdapter? + + /* + * Batch handling. + */ + + @Transaction + @SqlBatch("""INSERT INTO sim_entries + (batch, profileVendorId, hlrid, hlrState, smdpplusstate, provisionState, matchingId, profile, iccid, imsi, msisdn, pin1, pin2, puk1, puk2) + VALUES (:batch, :profileVendorId, :hssId, :hssState, :smdpPlusState, :provisionState, :matchingId, :profile, :iccid, :imsi, :msisdn, :pin1, :pin2, :puk1, :puk2)""") + @BatchChunkSize(1000) + fun insertAll(@BindBean entries: Iterator) + + @SqlUpdate("""INSERT INTO sim_import_batches (status, importer, hlrId, profileVendorId) + VALUES ('STARTED', :importer, :hssId, :profileVendorId)""") + fun createNewSimImportBatch(importer: String, + hssId: Long, + profileVendorId: Long): Int + + @SqlUpdate("""UPDATE sim_import_batches SET size = :size, + status = :status, + endedAt = :endedAt + WHERE id = :id""") + fun updateBatchState(id: Long, + size: Long, + status: String, + endedAt: Long): Int + + @SqlQuery("""SELECT * FROM sim_import_batches + WHERE id = :id""") + fun getBatchInfo(id: Long): SimImportBatch? + + /* + * Returns the 'id' of the last insert, regardless of table. + */ + @SqlQuery("SELECT lastval()") + fun lastInsertedRowId(): Long + + /** + * Find all the different HLRs that are present. + */ + @SqlQuery("SELECT * FROM hlr_adapters") + // TODO(RMZ): @RegisterMapper(HlrEntryMapper::class) + @RegisterRowMapper(HlrEntryMapper::class) + fun getHssEntries(): List + + + /** + * Find the names of profiles that are associated with + * a particular HLR. + */ + @SqlQuery("""SELECT DISTINCT profile FROM sim_entries + WHERE hlrId = :hssId""") + fun getProfileNamesForHss(hssId: Long): List + + /** + * Get key numbers from a particular named Sim profile. + * NOTE: This method is intended as an internal helper method for getProfileStats, its signature + * can change at any time, so don't use it unless you really know what you're doing. + */ + @SqlQuery(""" + SELECT 'NO_OF_ENTRIES' AS KEY, count(*) AS VALUE FROM sim_entries WHERE hlrId = :hssId AND profile = :simProfile + UNION + SELECT 'NO_OF_UNALLOCATED_ENTRIES' AS KEY, count(*) AS VALUE FROM sim_entries + WHERE hlrId = :hssId AND profile = :simProfile AND + smdpPlusState = :smdpUnallocatedState AND + hlrState = :hlrUnallocatedState AND + provisionState = :provisionedAvailableState + UNION + SELECT 'NO_OF_RELEASED_ENTRIES' AS KEY, count(*) AS VALUE FROM sim_entries + WHERE hlrId = :hssId AND profile = :simProfile AND + smdpPlusState = :smdpReleasedState AND + hlrState = :hssAllocatedState + UNION + SELECT 'NO_OF_ENTRIES_READY_FOR_IMMEDIATE_USE' AS KEY, count(*) AS VALUE FROM sim_entries + WHERE hlrId = :hssId AND profile = :simProfile AND + smdpPlusState = :smdpReleasedState AND + hlrState = :hssAllocatedState AND + provisionState = :provisionedAvailableState + """) + @RegisterRowMapper(KeyValueMapper::class) + fun getProfileStatsAsKeyValuePairs( + hssId: Long, + simProfile: String, + smdpReleasedState: String = SmDpPlusState.RELEASED.name, + hlrUnallocatedState: String = HssState.NOT_ACTIVATED.name, + smdpUnallocatedState: String = SmDpPlusState.AVAILABLE.name, + hssAllocatedState: String = HssState.ACTIVATED.name, + smdpAllocatedState: String = SmDpPlusState.ALLOCATED.name, + smdpDownloadedState: String = SmDpPlusState.DOWNLOADED.name, + provisionedAvailableState: String = ProvisionState.AVAILABLE.name): List +} diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDBWrapper.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDBWrapper.kt new file mode 100644 index 000000000..b6caaadff --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDBWrapper.kt @@ -0,0 +1,145 @@ +package org.ostelco.simcards.inventory + +import arrow.core.Either +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.simcards.profilevendors.ProfileVendorAdapter +import org.ostelco.simcards.hss.HssEntry + + +interface SimInventoryDBWrapper { + + fun getSimProfileById(id: Long): Either + + fun getSimProfileByIccid(iccid: String): Either + + fun getSimProfileByImsi(imsi: String): Either + + fun getSimProfileByMsisdn(msisdn: String): Either + + fun findNextNonProvisionedSimProfileForHss(hssId: Long, profile: String): Either + + fun findNextReadyToUseSimProfileForHss(hssId: Long, profile: String): Either + + /** + * Sets the EID value of a SIM entry (profile). + * @param iccid SIM entry to update + * @param eid the eid value + * @return updated SIM entry + */ + fun setEidOfSimProfileByIccid(iccid: String, eid: String): Either + + /** + * Sets the EID value of a SIM entry (profile). + * @param id row to update + * @param eid the eid value + * @return updated SIM entry + */ + fun setEidOfSimProfile(id: Long, eid: String): Either + + /* + * State information. + */ + + /** + * Set the entity to be marked as "active" in the HSS, then return the + * SIM entry. + * @param id row to update + * @param state new state from HSS service interaction + * @return updated row or null on no match + */ + fun setHssState(id: Long, state: HssState): Either + + /** + * Set the provision state of a SIM entry, then return the entry. + * @param id row to update + * @param state new state from HSS service interaction + * @return updated row or null on no match + */ + fun setProvisionState(id: Long, state: ProvisionState): Either + + /** + * Updates state of SIM profile and returns the updated profile. + * @param id row to update + * @param state new state from SMDP+ service interaction + * @return updated row or null on no match + */ + fun setSmDpPlusState(id: Long, state: SmDpPlusState): Either + + /** + * Updates state of SIM profile and returns the updated profile. + * @param iccid SIM entry to update + * @param state new state from SMDP+ service interaction + * @return updated row or null on no match + */ + fun setSmDpPlusStateUsingIccid(iccid: String, state: SmDpPlusState): Either + + /** + * Updates state of SIM profile and returns the updated profile. + * Updates state and the 'matching-id' of a SIM profile and return + * the updated profile. + * @param id row to update + * @param state new state from SMDP+ service interaction + * @param matchingId SM-DP+ ES2 'matching-id' to be sent to handset + * @return updated row or null on no match + */ + fun setSmDpPlusStateAndMatchingId(id: Long, state: SmDpPlusState, matchingId: String): Either + + /* + * HSS and SM-DP+ 'adapters'. + */ + + fun findSimVendorForHssPermissions(profileVendorId: Long, hssId: Long): Either> + + fun storeSimVendorForHssPermission(profileVendorId: Long, hssId: Long): Either + + fun addHssEntry(name: String): Either + + fun getHssEntryByName(name: String): Either + + fun getHssEntryById(id: Long): Either + + fun addProfileVendorAdapter(name: String): Either + + fun getProfileVendorAdapterByName(name: String): Either + + fun getProfileVendorAdapterById(id: Long): Either + + /* + * Batch handling. + */ + + fun insertAll(entries: Iterator): Either + + fun createNewSimImportBatch(importer: String, hssId: Long, profileVendorId: Long): Either + + fun updateBatchState(id: Long, size: Long, status: String, endedAt: Long): Either + + fun getBatchInfo(id: Long): Either + + /* + * Returns the 'id' of the last insert, regardless of table. + */ + + fun lastInsertedRowId(): Either + + /** + * Find all the different HSSes that are present. + */ + + fun getHssEntries(): Either> + + /** + * Find the names of profiles that are associated with + * a particular HSS. + */ + + fun getProfileNamesForHssById(hssId: Long): Either> + + /** + * Get key numbers from a particular named Sim profile. + * NOTE: This method is intended as an internal helper method for getProfileStats, its signature + * can change at any time, so don't use it unless you really know what you're doing. + */ + + fun getProfileStatsAsKeyValuePairs(hssId: Long, simProfile: String): Either> +} \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDBWrapperImpl.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDBWrapperImpl.kt new file mode 100644 index 000000000..ca203c834 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryDBWrapperImpl.kt @@ -0,0 +1,242 @@ +package org.ostelco.simcards.inventory + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import org.jdbi.v3.core.JdbiException +import org.jdbi.v3.sqlobject.transaction.Transaction +import org.ostelco.prime.simmanager.* +import org.ostelco.simcards.hss.HssEntry +import org.ostelco.simcards.profilevendors.ProfileVendorAdapter +import org.postgresql.util.PSQLException + + +class SimInventoryDBWrapperImpl(private val db: SimInventoryDB) : SimInventoryDBWrapper { + + override fun getSimProfileById(id: Long): Either = + either(NotFoundError("Found no SIM for id ${id}")) { + db.getSimProfileById(id) + } + + override fun getSimProfileByIccid(iccid: String): Either = + either(NotFoundError("Found no SIM for ICCID ${iccid}")) { + db.getSimProfileByIccid(iccid) + } + + override fun getSimProfileByImsi(imsi: String): Either = + either(NotFoundError("Found no SIM for IMSI ${imsi}")) { + db.getSimProfileByImsi(imsi) + } + + override fun getSimProfileByMsisdn(msisdn: String): Either = + either(NotFoundError("Found no SIM MSISDN ${msisdn}")) { + db.getSimProfileByMsisdn(msisdn) + } + + override fun findNextNonProvisionedSimProfileForHss(hssId: Long, profile: String): Either = + either(NotFoundError("No uprovisioned SIM available for HSS id ${hssId} and profile ${profile}")) { + db.findNextNonProvisionedSimProfileForHss(hssId, profile) + } + + // err + override fun findNextReadyToUseSimProfileForHss(hssId: Long, profile: String): Either = + either(NotFoundError("No ready to use SIM available for HSS id ${hssId} and profile ${profile}")) { + db.findNextReadyToUseSimProfileForHlr(hssId, profile) + } + + @Transaction + override fun setEidOfSimProfileByIccid(iccid: String, eid: String): Either = + either(NotFoundError("Found no SIM profile with ICCID ${iccid} update of EID failed")) { + if (db.updateEidOfSimProfileByIccid(iccid, eid) > 0) + db.getSimProfileByIccid(iccid) + else + null + } + + @Transaction + override fun setEidOfSimProfile(id: Long, eid: String): Either = + either(NotFoundError("Found no SIM profile with id ${id} update of EID failed")) { + if (db.updateEidOfSimProfile(id, eid) > 0) + db.getSimProfileById(id) + else + null + } + + /* + * State information. + */ + + @Transaction + override fun setHssState(id: Long, state: HssState): Either = + either(NotFoundError("Found no HSS profilevendors with id ${id} update of HSS state failed")) { + if (db.updateHlrState(id, state) > 0) + db.getSimProfileById(id) + else + null + } + + @Transaction + override fun setProvisionState(id: Long, state: ProvisionState): Either = + either(NotFoundError("Found no SIM profile with id ${id} update of provision state failed")) { + if (db.updateProvisionState(id, state) > 0) + db.getSimProfileById(id) + else + null + } + + @Transaction + override fun setSmDpPlusState(id: Long, state: SmDpPlusState): Either = + either(NotFoundError("Found no SIM profile with id ${id} update of SM-DP+ state failed")) { + if (db.updateSmDpPlusState(id, state) > 0) + db.getSimProfileById(id) + else + null + } + + @Transaction + override fun setSmDpPlusStateUsingIccid(iccid: String, state: SmDpPlusState): Either = + either(NotFoundError("Found no SIM profile with id ${iccid} update of SM-DP+ state failed")) { + if (db.updateSmDpPlusStateUsingIccid(iccid, state) > 0) + db.getSimProfileByIccid(iccid) + else + null + } + + @Transaction + override fun setSmDpPlusStateAndMatchingId(id: Long, state: SmDpPlusState, matchingId: String): Either = + either(NotFoundError("Found no SIM profile with id ${id} update of SM-DP+ state and 'matching-id' failed")) { + if (db.updateSmDpPlusStateAndMatchingId(id, state, matchingId) > 0) + db. getSimProfileById(id) + else + null + } + + /* + * Hss and SM-DP+ 'adapters'. + */ + + override fun findSimVendorForHssPermissions(profileVendorId: Long, hssId: Long): Either> = + either(ForbiddenError("Using SIM profile vendor id ${profileVendorId} with HSS id ${hssId} is not allowed")) { + db.findSimVendorForHssPermissions(profileVendorId, hssId) + } + + override fun storeSimVendorForHssPermission(profileVendorId: Long, hssId: Long): Either = + either { +- db.storeSimVendorForHssPermission(profileVendorId, hssId) + } + + override fun addHssEntry(name: String): Either = + either { + db.addHssAdapter(name) + } + + override fun getHssEntryByName(name: String): Either = + either(NotFoundError("Found no HSS entry with name ${name}")) { + db.getHssEntryByName(name) + } + + override fun getHssEntryById(id: Long): Either = + either(NotFoundError("Found no HSS entry with id ${id}")) { + db.getHssEntryById(id) + } + + override fun addProfileVendorAdapter(name: String): Either = + either { + db.addProfileVendorAdapter(name) + } + + override fun getProfileVendorAdapterByName(name: String): Either = + either(NotFoundError("Found no SIM profile vendor with name ${name}")) { + db.getProfileVendorAdapterByName(name) + } + + override fun getProfileVendorAdapterById(id: Long): Either = + either(NotFoundError("Found no SIM profile vendor with id ${id}")) { + db.getProfileVendorAdapterById(id) + } + + /* + * Batch handling. + */ + + override fun insertAll(entries: Iterator): Either = + either { + db.insertAll(entries) + } + + override fun createNewSimImportBatch(importer: String, hssId: Long, profileVendorId: Long): Either = + either { + db.createNewSimImportBatch(importer, hssId, profileVendorId) + } + + override fun updateBatchState(id: Long, size: Long, status: String, endedAt: Long): Either = + either { + db.updateBatchState(id, size, status, endedAt) + } + + override fun getBatchInfo(id: Long): Either = + either(NotFoundError("Found no information about 'import batch' with id ${id}")) { + db.getBatchInfo(id) + } + + /** + * Returns the 'id' of the last insert, regardless of table. + */ + override fun lastInsertedRowId(): Either = + either { + db.lastInsertedRowId() + } + + /** + * Find all the different HLRs that are present. + */ + override fun getHssEntries(): Either> = + either(NotFoundError("Found no HSS adapters")) { + db.getHssEntries() + } + + /** + * Find the names of profiles that are associated with + * a particular HSS. + */ + override fun getProfileNamesForHssById(hssId: Long): Either> = + either(NotFoundError("Found no SIM profile name for HSS with id ${hssId}")) { + db.getProfileNamesForHss(hssId) + } + + /** + * Get key numbers from a particular named Sim profile. + * NOTE: This method is intended as an internal helper method for getProfileStats, its signature + * can change at any time, so don't use it unless you really know what you're doing. + */ + override fun getProfileStatsAsKeyValuePairs(hssId: Long, simProfile: String): Either> = + either(NotFoundError("Found no statistics for SIM profile ${simProfile} for HSS with id ${hssId}")) { + db.getProfileStatsAsKeyValuePairs(hssId, simProfile) + } + + /* Convenience functions. */ + + private fun either(action: () -> R): Either = + try { + action().right() + } catch (e: Exception) { + when (e) { + is JdbiException, + is PSQLException -> + DatabaseError("SIM manager database query failed with message: ${e.message}") + else -> SystemError("Error accessing SIM manager database: ${e.message}") + }.left() + } + + private fun either(error: SimManagerError, action: () -> R?): Either = + try { + action()?.right() ?: error.left() + } + catch (e: Exception) { + when (e) { + is JdbiException, + is PSQLException -> DatabaseError("SIM manager database query failed with message: ${e.message}") + else -> SystemError("Error accessing SIM manager database: ${e.message}") + }.left() + } +} diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryResource.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryResource.kt new file mode 100644 index 000000000..704d82b61 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/inventory/SimInventoryResource.kt @@ -0,0 +1,129 @@ +package org.ostelco.simcards.inventory + +import org.hibernate.validator.constraints.NotEmpty +import org.ostelco.prime.apierror.ApiErrorCode +import org.ostelco.prime.apierror.ApiErrorMapper.mapSimManagerErrorToApiError +import org.ostelco.prime.jsonmapper.asJson +import org.ostelco.prime.simmanager.SimManagerError +import java.io.IOException +import java.io.InputStream +import javax.ws.rs.Consumes +import javax.ws.rs.DefaultValue +import javax.ws.rs.GET +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.PathParam +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + + +/// +/// The web resource using the protocol domain model. +/// + +@Path("/ostelco/sim-inventory/{hssVendors}") +class SimInventoryResource(private val api: SimInventoryApi) { + + @GET + @Path("profileStatusList/{iccid}") + @Produces(MediaType.APPLICATION_JSON) + fun getSimProfileStatus( + @NotEmpty @PathParam("hssVendors") hlrName: String, + @NotEmpty @PathParam("iccid") iccid: String): Response = + api.getSimProfileStatus(hlrName, iccid) + .fold( + { + error("Failed to fetch SIM profile from vendor for BSS: ${hlrName} and ICCID: ${iccid}", + ApiErrorCode.FAILED_TO_FETCH_SIM_PROFILE, it) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + + @GET + @Path("iccid/{iccid}") + @Produces(MediaType.APPLICATION_JSON) + fun findSimProfileByIccid( + @NotEmpty @PathParam("hssVendors") hlrName: String, + @NotEmpty @PathParam("iccid") iccid: String): Response = + api.findSimProfileByIccid(hlrName, iccid) + .fold( + { + error("Failed to find SIM profile for BSS: ${hlrName} and ICCID: ${iccid}", + ApiErrorCode.FAILED_TO_FETCH_SIM_PROFILE, it) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + + + @GET + @Path("imsi/{imsi}") + @Produces(MediaType.APPLICATION_JSON) + fun findSimProfileByImsi( + @NotEmpty @PathParam("hssVendors") hlrName: String, + @NotEmpty @PathParam("imsi") imsi: String): Response = + api.findSimProfileByImsi(hlrName, imsi) + .fold( + { + error("Failed to find SIM profile for BSS: ${hlrName} and IMSI: ${imsi}", + ApiErrorCode.FAILED_TO_FETCH_SIM_PROFILE, it) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + + @GET + @Path("msisdn/{msisdn}") + @Produces(MediaType.APPLICATION_JSON) + fun findSimProfileByMsisdn( + @NotEmpty @PathParam("hssVendors") hlrName: String, + @NotEmpty @PathParam("msisdn") msisdn: String): Response = + api.findSimProfileByMsisdn(hlrName, msisdn) + .fold( + { + error("Failed to find SIM profile for BSS: ${hlrName} and MSISDN: ${msisdn}", + ApiErrorCode.FAILED_TO_FETCH_SIM_PROFILE, it) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + + + @GET + @Path("esim") + @Produces(MediaType.APPLICATION_JSON) + fun allocateNextEsimProfile( + @NotEmpty @PathParam("hssVendors") hlrName: String, + @DefaultValue("_") @QueryParam("phoneType") phoneType: String): Response = + api.allocateNextEsimProfile(hlrName, phoneType) + .fold( + { + error("Failed to reserve SIM profile with BSS ${hlrName}", + ApiErrorCode.FAILED_TO_RESERVE_ACTIVATED_SIM_PROFILE, it) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + + @PUT + @Path("import-batch/profilevendor/{simVendor}") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.TEXT_PLAIN) + @Throws(IOException::class) + fun importBatch( + @NotEmpty @PathParam("hssVendors") hss: String, + @NotEmpty @PathParam("simVendor") simVendor: String, + csvInputStream: InputStream): Response = + api.importBatch(hss, simVendor, csvInputStream) + .fold( + { + error("Failed to upload batch with SIM profiles for HSS ${hss} and SIM profile vendor ${simVendor}", + ApiErrorCode.FAILED_TO_IMPORT_BATCH, it) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() + + /* Maps internal errors to format suitable for HTTP/REST. */ + private fun error(description: String, code: ApiErrorCode, error: SimManagerError): Response.ResponseBuilder { + val apiError = mapSimManagerErrorToApiError(description, code, error) + return Response.status(apiError.status).entity(asJson(apiError)) + } +} diff --git a/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/profilevendors/ProfileVendorAdapter.kt b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/profilevendors/ProfileVendorAdapter.kt new file mode 100644 index 000000000..8ab87dcc6 --- /dev/null +++ b/sim-administration/simmanager/src/main/kotlin/org/ostelco/simcards/profilevendors/ProfileVendorAdapter.kt @@ -0,0 +1,338 @@ +package org.ostelco.simcards.profilevendors + +import arrow.core.Either +import arrow.core.flatMap +import arrow.core.left +import arrow.core.right +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.http.client.methods.RequestBuilder +import org.apache.http.entity.StringEntity +import org.apache.http.impl.client.CloseableHttpClient +import org.ostelco.prime.getLogger +import org.ostelco.prime.simmanager.AdapterError +import org.ostelco.prime.simmanager.NotFoundError +import org.ostelco.prime.simmanager.NotUpdatedError +import org.ostelco.prime.simmanager.SimManagerError +import org.ostelco.sim.es2plus.* +import org.ostelco.simcards.admin.ProfileVendorConfig +import org.ostelco.simcards.inventory.SimEntry +import org.ostelco.simcards.inventory.SimInventoryDAO +import org.ostelco.simcards.inventory.SmDpPlusState +import java.util.* +import javax.ws.rs.core.MediaType + +/** + * An profilevendors that can connect to SIM profile vendors and activate + * the requested SIM profile. + * + * Will connect to the SM-DP+ and then activate the profile, so that when + * user equpiment tries to download a profile, it will get a profile to + * download. + */ +data class ProfileVendorAdapter( + @JsonProperty("id") val id: Long, + @JsonProperty("name") val name: String) { + + private val logger by getLogger() + + /* For payload serializing. */ + private val mapper = jacksonObjectMapper() + + /** + * Requests the an external Profile Vendor to activate the + * SIM profile. + * @param client HTTP client + * @param config SIM vendor specific configuration + * @param dao DB interface + * @param eid ESIM id + * @param simEntry SIM profile to activate + * @return Updated SIM profile + */ + fun activate(httpClient: CloseableHttpClient, + config: ProfileVendorConfig, + dao: SimInventoryDAO, + eid: String? = null, + simEntry: SimEntry): Either = + downloadOrder(httpClient, config, dao, simEntry) + .flatMap { + confirmOrder(httpClient, config, dao, eid, it) + } + + /** + * Initiate activation of a SIM profile with an external Profile Vendor + * by sending a SM-DP+ 'download-order' message. + * @param client HTTP client + * @param config SIM vendor specific configuration + * @param dao DB interface + * @param simEntry SIM profile to activate + * @return Updated SIM profile + */ + private fun downloadOrder(httpClient: CloseableHttpClient, + config: ProfileVendorConfig, + dao: SimInventoryDAO, + simEntry: SimEntry): Either { + val header = ES2RequestHeader( + functionRequesterIdentifier = config.requesterIndentifier, + functionCallIdentifier = "downloadOrder" + ) + val body = Es2PlusDownloadOrder( + header = header, + iccid = simEntry.iccid + ) + val payload = mapper.writeValueAsString(body) + + val request = RequestBuilder.post() + .setUri("${config.es2plusEndpoint}/downloadOrder") + .setHeader("User-Agent", "gsma-rsp-lpad") + .setHeader("X-Admin-Protocol", "gsma/rsp/v2.0.0") + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + .setEntity(StringEntity(payload)) + .build() + + return try { + httpClient.execute(request).use { + when (it.statusLine.statusCode) { + 200 -> { + val status = mapper.readValue(it.entity.content, Es2DownloadOrderResponse::class.java) + + if (status.header.functionExecutionStatus.status != FunctionExecutionStatusType.ExecutedSuccess) { + logger.error("SM-DP+ 'order-download' message to service {} for ICCID {} failed with execution status {} (call-id: {})", + config.name, + simEntry.iccid, + status.header.functionExecutionStatus, + header.functionCallIdentifier) + NotUpdatedError("SM-DP+ 'order-download' to ${config.name} failed with status: ${status.header.functionExecutionStatus}") + .left() + } else { + logger.info("SM-DP+ 'order-download' message to service {} for ICCID {} completed OK (call-id: {})", + config.name, + simEntry.iccid, + header.functionCallIdentifier) + dao.setSmDpPlusState(simEntry.id!!, SmDpPlusState.ALLOCATED) + } + } + else -> { + logger.error("SM-DP+ 'order-download' message to service {} for ICCID {} failed with status code {} (call-id: {})", + config.name, + simEntry.iccid, + it.statusLine.statusCode, + header.functionCallIdentifier) + NotUpdatedError("SM-DP+ 'order-download' to ${config.name} failed with code: ${it.statusLine.statusCode}") + .left() + } + } + } + } catch (e: Exception) { + logger.error("SM-DP+ 'order-download' message to service {} for ICCID {} failed with error: {}", + config.name, + simEntry.iccid, + e) + AdapterError("SM-DP+ 'order-download' message to service ${config.name} failed with error: ${e}") + .left() + } + } + + /** + * Complete the activation of a SIM profile with an external Profile Vendor + * by sending a SM-DP+ 'confirmation' message. + * @param client HTTP client + * @param config SIM vendor specific configuration + * @param dao DB interface + * @param eid ESIM id + * @param simEntry SIM profile to activate + * @return Updated SIM profile + */ + private fun confirmOrder(httpClient: CloseableHttpClient, + config: ProfileVendorConfig, + dao: SimInventoryDAO, + eid: String? = null, + simEntry: SimEntry): Either { + val header = ES2RequestHeader( + functionRequesterIdentifier = config.requesterIndentifier, + functionCallIdentifier = UUID.randomUUID().toString() + ) + val body = Es2ConfirmOrder( + header = header, + eid = eid, + iccid = simEntry.iccid, + releaseFlag = true + ) + val payload = mapper.writeValueAsString(body) + + val request = RequestBuilder.post() + .setUri("${config.es2plusEndpoint}/confirmOrder") + .setHeader("User-Agent", "gsma-rsp-lpad") + .setHeader("X-Admin-Protocol", "gsma/rsp/v2.0.0") + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + .setEntity(StringEntity(payload)) + .build() + + return try { + httpClient.execute(request).use { + when (it.statusLine.statusCode) { + 200 -> { + val status = mapper.readValue(it.entity.content, Es2ConfirmOrderResponse::class.java) + + if (status.header.functionExecutionStatus.status != FunctionExecutionStatusType.ExecutedSuccess) { + logger.error("SM-DP+ 'order-confirm' message to service {} for ICCID {} failed with execution status {} (call-id: {})", + config.name, + simEntry.iccid, + status.header.functionExecutionStatus, + header.functionCallIdentifier) + NotUpdatedError("SM-DP+ 'order-confirm' to ${config.name} failed with status: ${status.header.functionExecutionStatus}") + .left() + } else { + // XXX Is just logging good enough? + if (status.eid.isNullOrEmpty()) { + logger.warn("No EID returned from service {} for ICCID {} for SM-DP+ 'order-confirm' message (call-id: {})", + config.name, + simEntry.iccid, + header.functionCallIdentifier) + } else { + dao.setEidOfSimProfile(simEntry.id!!, status.eid!!) + } + if (!eid.isNullOrEmpty() && eid != status.eid) { + logger.warn("EID returned from service {} does not match provided EID ({} <> {}) in SM-DP+ 'order-confirm' message (call-id: {})", + config.name, + eid, + status.eid, + header.functionCallIdentifier) + } + logger.info("SM-DP+ 'order-confirm' message to service {} for ICCID {} completed OK (call-id: {})", + config.name, + simEntry.iccid, + header.functionCallIdentifier) + dao.setSmDpPlusStateAndMatchingId(simEntry.id!!, SmDpPlusState.RELEASED, status.matchingId!!) + } + } + else -> { + logger.error("SM-DP+ 'order-confirm' message to service {} for ICCID {} failed with status code %d (call-id: {})", + config.name, + simEntry.iccid, + it.statusLine.statusCode, + header.functionCallIdentifier) + NotUpdatedError("SM-DP+ 'order-confirm' to ${config.name} failed with code: ${it.statusLine.statusCode}") + .left() + } + } + } + } catch (e: Exception) { + logger.error("SM-DP+ 'order-confirm' message to service {} for ICCID {} failed with error: {}", + config.name, + simEntry.iccid, + e) + AdapterError("SM-DP+ 'order-confirm' message to service ${config.name} failed with error: ${e}") + .left() + } + } + + /** + * Downloads the SM-DP+ 'profile status' information for an ICCID from + * a SM-DP+ service. + * @param client HTTP client + * @param config SIM vendor specific configuration + * @param iccid ICCID + * @return SM-DP+ 'profile status' for ICCID + */ + fun getProfileStatus(httpClient: CloseableHttpClient, + config: ProfileVendorConfig, + iccid: String): Either = + getProfileStatus(httpClient, config, listOf(iccid)) + .flatMap { + it.first().right() + } + + /* XXX Missing: + 1. unit tests + 2. enabled integration test - depends on support in SM-DP+ emulator */ + + /** + * Downloads the SM-DP+ 'profile status' information for a list of ICCIDs + * from a SM-DP+ service. + * @param client HTTP client + * @param config SIM vendor specific configuration + * @param iccidList list with ICCID + * @return A list with SM-DP+ 'profile status' information + */ + private fun getProfileStatus(httpClient: CloseableHttpClient, + config: ProfileVendorConfig, + iccidList: List): Either> { + if (iccidList.isNullOrEmpty()) { + logger.error("One or more ICCID values required in SM-DP+ 'profile-status' message to service {}", + config.name) + return NotFoundError("").left() + } + + val header = ES2RequestHeader( + functionRequesterIdentifier = config.requesterIndentifier, + functionCallIdentifier = UUID.randomUUID().toString() + ) + val body = Es2PlusProfileStatus( + header = header, + iccidList = iccidList.map { IccidListEntry(iccid = it) } + ) + val payload = mapper.writeValueAsString(body) + + val request = RequestBuilder.post() + .setUri("${config.es2plusEndpoint}/getProfileStatus") + .setHeader("User-Agent", "gsma-rsp-lpad") + .setHeader("X-Admin-Protocol", "gsma/rsp/v2.0.0") + .setHeader("Content-Type", MediaType.APPLICATION_JSON) + .setEntity(StringEntity(payload)) + .build() + + /* Pretty print version of ICCID list. */ + val iccids = iccidList.joinToString(prefix = "[", postfix = "]") + + return try { + httpClient.execute(request).use { + when (it.statusLine.statusCode) { + 200 -> { + val status = mapper.readValue(it.entity.content, Es2ProfileStatusResponse::class.java) + + if (status.header.functionExecutionStatus.status != FunctionExecutionStatusType.ExecutedSuccess) { + + logger.error("SM-DP+ 'profile-status' message to service {} for ICCID {} failed with execution status {} (call-id: {})", + config.name, + iccids, + status.header.functionExecutionStatus, + header.functionCallIdentifier) + NotUpdatedError("SM-DP+ 'profile-status' to ${config.name} failed with status: ${status.header.functionExecutionStatus}") + .left() + + } else { + logger.info("SM-DP+ 'profile-status' message to service {} for ICCID {} completed OK (call-id: {})", + config.name, + iccids, + header.functionCallIdentifier) + val profileStatusList = status.profileStatusList + + if (!profileStatusList.isNullOrEmpty()) + profileStatusList.right() + else + NotFoundError("No information found for ICCID ${iccids} in SM-DP+ 'profile-status' message to service ${config.name}") + .left() + } + } + else -> { + logger.error("SM-DP+ 'profile-status' message to service {} for ICCID {} failed with status code %d (call-id: {})", + config.name, + iccids, + it.statusLine.statusCode, + header.functionCallIdentifier) + NotUpdatedError("SM-DP+ 'order-confirm' to ${config.name} failed with code: ${it.statusLine.statusCode}") + .left() + } + } + } + } catch (e: Exception) { + logger.error("SM-DP+ 'profile-status' message to service {} for ICCID {} failed with error: {}", + config.name, + iccids, + e) + AdapterError("SM-DP+ 'profile-status' message to service ${config.name} failed with error: ${e}") + .left() + } + } +} \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/proto/hss-adapter-api.proto b/sim-administration/simmanager/src/main/proto/hss-adapter-api.proto new file mode 100644 index 000000000..f952820a0 --- /dev/null +++ b/sim-administration/simmanager/src/main/proto/hss-adapter-api.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package org.ostelco.simcards.hss.adapter.api; + +option java_multiple_files = true; +option java_package = "org.ostelco.simcards.hss.profilevendors.api"; + + +service HssService { + rpc activate (ActivationRequest) returns (HssServiceResponse); + rpc suspend (SuspensionRequest) returns (HssServiceResponse); + rpc getHealthStatus (ServiceHealthQuery) returns (ServiceHealthStatus); +} + +message ActivationRequest { + string hss = 1; + string iccid = 2; + string msisdn = 3; +} + +message SuspensionRequest { + string hss = 1; + string iccid = 2; +} + +message HssServiceResponse { + bool success = 1; + string reply = 2; +} + +message ServiceHealthStatus { + bool isHealthy = 1; +} + +message ServiceHealthQuery {} diff --git a/sim-administration/simmanager/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable b/sim-administration/simmanager/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable new file mode 100644 index 000000000..8056fe23b --- /dev/null +++ b/sim-administration/simmanager/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable @@ -0,0 +1 @@ +org.ostelco.prime.module.PrimeModule \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule b/sim-administration/simmanager/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule new file mode 100644 index 000000000..985db38c0 --- /dev/null +++ b/sim-administration/simmanager/src/main/resources/META-INF/services/org.ostelco.prime.module.PrimeModule @@ -0,0 +1 @@ +org.ostelco.simcards.admin.SimAdministrationModule \ No newline at end of file diff --git a/sim-administration/simmanager/src/main/resources/META-INF/services/org.ostelco.prime.sim.SimManager b/sim-administration/simmanager/src/main/resources/META-INF/services/org.ostelco.prime.sim.SimManager new file mode 100644 index 000000000..698d75049 --- /dev/null +++ b/sim-administration/simmanager/src/main/resources/META-INF/services/org.ostelco.prime.sim.SimManager @@ -0,0 +1 @@ +org.ostelco.simcards.admin.ESimManager \ No newline at end of file diff --git a/sim-administration/simmanager/src/test/kotlin/org/ostelco/simcards/admin/GenerateBatchDescriptionTest.kt b/sim-administration/simmanager/src/test/kotlin/org/ostelco/simcards/admin/GenerateBatchDescriptionTest.kt new file mode 100644 index 000000000..1f9ca4e4d --- /dev/null +++ b/sim-administration/simmanager/src/test/kotlin/org/ostelco/simcards/admin/GenerateBatchDescriptionTest.kt @@ -0,0 +1,79 @@ +package org.ostelco.simcards.admin + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.ostelco.simcards.IccidBasis + +class GenerateBatchDescriptionTest { + + + @Test + fun testGenerateIccid() { + val iccid = IccidBasis(cc = 47, serialNumber = 1) + val iccidString = iccid.asIccid() + assertEquals(19, iccidString.length) + } + + @Test + fun funPrettyPrintBatchDescription() { + val iccid = IccidBasis(cc = 47, serialNumber = 1).asIccid() + + val batch = SimBatchDescription( + customer = "FooTel", + profileType = "FooTelStd", + orderDate = "20181212", + batchNo = 1, + quantity = 1, + iccidStart = iccid, + imsiStart = "4201710010000", + opKeyLabel = "FooTel-OP", + transportKeyLabel = "FooTel-TK-1" + ) + + val pp = prettyPrintSimBatchDescription(batch) + println(pp) + assertTrue(pp.length > 100) + } + + + private fun prettyPrintSimBatchDescription(bd: SimBatchDescription): String { + return """ + *HEADER DESCRIPTION + *************************************** + Customer : ${bd.customer} + ProfileType : ${bd.profileType} + Order Date : ${bd.orderDate} + Batch No : ${bd.orderDate}${bd.batchNo} + Quantity : ${bd.quantity} + OP Key label : + Transport Key : + *************************************** + *INPUT VARIABLES + *************************************** + var_In: + ICCID: ${bd.iccidStart} + IMSI: ${bd.imsiStart} + *************************************** + *OUTPUT VARIABLES + *************************************** + var_Out: ICCID/IMSI/KI + """.trimIndent() + } +} + + +// TODO: This is just a first iteration, things like dates etc. should be +// not be represented using strings but proper time-objects, but we'll do this for now +// just too get going. + +class SimBatchDescription( + val customer: String, + val profileType: String, + val orderDate: String, + val batchNo: Int, + val quantity: Int, + val iccidStart: String, + val imsiStart: String, + val opKeyLabel: String, + val transportKeyLabel: String) diff --git a/sim-administration/simmanager/src/test/kotlin/org/ostelco/simcards/inventory/SimInventoryUnitTests.kt b/sim-administration/simmanager/src/test/kotlin/org/ostelco/simcards/inventory/SimInventoryUnitTests.kt new file mode 100644 index 000000000..3e8e1d3b3 --- /dev/null +++ b/sim-administration/simmanager/src/test/kotlin/org/ostelco/simcards/inventory/SimInventoryUnitTests.kt @@ -0,0 +1,323 @@ +package org.ostelco.simcards.inventory + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import io.dropwizard.testing.junit.ResourceTestRule +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import org.apache.http.impl.client.CloseableHttpClient +import org.junit.AfterClass +import org.junit.Before +import org.junit.ClassRule +import org.junit.Ignore +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.ostelco.prime.simmanager.NotFoundError +import org.ostelco.simcards.admin.HssConfig +import org.ostelco.simcards.admin.ProfileVendorConfig +import org.ostelco.simcards.admin.SimAdministrationConfiguration +import org.ostelco.simcards.hss.HssEntry +import org.ostelco.simcards.profilevendors.ProfileVendorAdapter +import java.io.ByteArrayInputStream +import java.util.* +import javax.ws.rs.client.Entity +import javax.ws.rs.core.MediaType + +class SimInventoryUnitTests { + + companion object { + private val config = mock(SimAdministrationConfiguration::class.java) + private val hssConfig = mock(HssConfig::class.java) + private val profileVendorConfig = mock(ProfileVendorConfig::class.java) + private val dao = mock(SimInventoryDAO::class.java) + private val hssEntry = mock(HssEntry::class.java) + private val profileVendorAdapter = mock(ProfileVendorAdapter::class.java) + private val httpClient = mock(CloseableHttpClient::class.java) + + @JvmField + @ClassRule + val RULE: ResourceTestRule = ResourceTestRule + .builder() + .addResource(SimInventoryResource(SimInventoryApi(httpClient, config, dao))) + .build() + + @JvmStatic + @AfterClass + fun afterClass() { + } + } + + private val fakeIccid1 = "01234567891234567890" + private val fakeIccid2 = "01234567891234567891" + private val fakeImsi1 = "12345678912345" + private val fakeImsi2 = "12345678912346" + private val fakeMsisdn1 = "474747474747" + private val fakeMsisdn2 = "464646464646" + private val fakeEid = "01010101010101010101010101010101" + + private val fakeProfileVendor = "Foo" + private val fakeHlr = "Bar" + private val fakePhoneType = "_" + private val fakeProfile = "PROFILE_1" + + private val matchingId = UUID.randomUUID().toString() + private val es9plusEndpoint = "http://localhost:8080/farFarAway" + + private val fakeSimEntryWithoutMsisdn = SimEntry( + id = 1L, + profileVendorId = 1L, + hssId = 1L, + msisdn = "", + eid = "", + profile = fakeProfile, + hssState = HssState.NOT_ACTIVATED, + smdpPlusState = SmDpPlusState.AVAILABLE, + batch = 99L, + imsi = fakeImsi1, + iccid = fakeIccid1, + matchingId = matchingId, + code = "LPA:1$$es9plusEndpoint$$matchingId") + + private val fakeSimEntryWithMsisdn = fakeSimEntryWithoutMsisdn.copy( + msisdn = fakeMsisdn1, + eid = fakeEid, + hssState = HssState.ACTIVATED, + smdpPlusState = SmDpPlusState.RELEASED + ) + + + @Before + fun setUp() { + reset(dao) + reset(hssEntry) + reset(profileVendorAdapter) + + /* HssConfig */ + org.mockito.Mockito.`when`(hssConfig.name) + .thenReturn(fakeHlr) + org.mockito.Mockito.`when`(hssConfig.endpoint) + .thenReturn("http://localhost:8080/nowhere") + + /* ProfileVendorConfig */ + org.mockito.Mockito.`when`(profileVendorConfig.name) + .thenReturn(fakeProfileVendor) + org.mockito.Mockito.`when`(profileVendorConfig.es2plusEndpoint) + .thenReturn("http://localhost:8080/somewhere") + org.mockito.Mockito.`when`(profileVendorConfig.es9plusEndpoint) + .thenReturn(es9plusEndpoint) + + /* Top level config. */ + org.mockito.Mockito.`when`(config.hssVendors) + .thenReturn(listOf(hssConfig)) + org.mockito.Mockito.`when`(config.profileVendors) + .thenReturn(listOf(profileVendorConfig)) + org.mockito.Mockito.`when`(config.getProfileForPhoneType(fakePhoneType)) + .thenReturn(fakeProfile) + + /* HLR profilevendors. */ + org.mockito.Mockito.`when`(hssEntry.id) + .thenReturn(1L) + org.mockito.Mockito.`when`(hssEntry.name) + .thenReturn(fakeHlr) + + + /* Profile vendor profilevendors. */ + org.mockito.Mockito.`when`(profileVendorAdapter.id) + .thenReturn(1L) + org.mockito.Mockito.`when`(profileVendorAdapter.name) + .thenReturn(fakeProfileVendor) + org.mockito.Mockito.`when`(profileVendorAdapter.activate(httpClient, profileVendorConfig, dao, null, fakeSimEntryWithoutMsisdn)) + .thenReturn(fakeSimEntryWithoutMsisdn.copy( + smdpPlusState = SmDpPlusState.RELEASED).right()) + + /* DAO. */ + org.mockito.Mockito.`when`(dao.getSimProfileByIccid(fakeIccid1)) + .thenReturn(fakeSimEntryWithoutMsisdn.right()) + + org.mockito.Mockito.`when`(dao.getSimProfileById(fakeSimEntryWithoutMsisdn.id!!)) + .thenReturn(fakeSimEntryWithoutMsisdn.right()) + + org.mockito.Mockito.`when`(dao.getSimProfileById(fakeSimEntryWithMsisdn.id!!)) + .thenReturn(fakeSimEntryWithMsisdn.right()) + + org.mockito.Mockito.`when`(dao.getSimProfileByIccid(fakeIccid2)) + .thenReturn(NotFoundError("").left()) + + org.mockito.Mockito.`when`(dao.getSimProfileByImsi(fakeImsi1)) + .thenReturn(fakeSimEntryWithoutMsisdn.right()) + + org.mockito.Mockito.`when`(dao.getSimProfileByImsi(fakeImsi2)) + .thenReturn(NotFoundError("").left()) + + org.mockito.Mockito.`when`(dao.getSimProfileByMsisdn(fakeMsisdn1)) + .thenReturn(fakeSimEntryWithMsisdn.right()) + + org.mockito.Mockito.`when`(dao.getSimProfileByMsisdn(fakeMsisdn2)) + .thenReturn(NotFoundError("").left()) + + org.mockito.Mockito.`when`(dao.findNextNonProvisionedSimProfileForHss(1L, fakeProfile)) + .thenReturn(fakeSimEntryWithoutMsisdn.right()) + + org.mockito.Mockito.`when`(dao.findNextReadyToUseSimProfileForHss(1L, fakeProfile)) + .thenReturn(fakeSimEntryWithoutMsisdn.right()) + + org.mockito.Mockito.`when`(dao.getHssEntryByName(fakeHlr)) + .thenReturn(Either.Right(hssEntry)) + + org.mockito.Mockito.`when`(dao.getProfileVendorAdapterByName(fakeProfileVendor)) + .thenReturn(profileVendorAdapter.right()) + + org.mockito.Mockito.`when`(dao.getProfileVendorAdapterById(1L)) + .thenReturn(profileVendorAdapter.right()) + + org.mockito.Mockito.`when`(dao.getHssEntryByName(fakeHlr)) + .thenReturn(Either.Right(hssEntry)) + + org.mockito.Mockito.`when`(dao.getHssEntryById(1L)) + .thenReturn(Either.Right(hssEntry)) + + org.mockito.Mockito.`when`(dao.setHssState(fakeSimEntryWithoutMsisdn.id!!, HssState.ACTIVATED)) + .thenReturn(fakeSimEntryWithoutMsisdn.copy( + hssState = HssState.ACTIVATED).right()) + + org.mockito.Mockito.`when`(dao.setHssState(fakeSimEntryWithoutMsisdn.id!!, HssState.NOT_ACTIVATED)) + .thenReturn(fakeSimEntryWithoutMsisdn.copy( + hssState = HssState.NOT_ACTIVATED).right()) + + org.mockito.Mockito.`when`(dao.setSmDpPlusState(fakeSimEntryWithoutMsisdn.id!!, SmDpPlusState.RELEASED)) + .thenReturn(fakeSimEntryWithoutMsisdn.copy( + smdpPlusState = SmDpPlusState.RELEASED).right()) + + org.mockito.Mockito.`when`(dao.setProvisionState(1L, ProvisionState.PROVISIONED)) + .thenReturn(fakeSimEntryWithoutMsisdn.right()) + + org.mockito.Mockito.`when`(config.getProfileForPhoneType(fakePhoneType)) + .thenReturn(fakeProfile) + } + + @Test + fun testFindByIccidPositiveResult() { + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/iccid/$fakeIccid1") + .request(MediaType.APPLICATION_JSON) + .get() + assertEquals(200, response.status) + + val simEntry = response.readEntity(SimEntry::class.java) + verify(dao).getSimProfileByIccid(fakeIccid1) + verify(dao).getProfileVendorAdapterById(fakeSimEntryWithoutMsisdn.profileVendorId) + assertNotNull(simEntry) + assertEquals(fakeSimEntryWithoutMsisdn, simEntry) + } + + @Test + fun testFindByIccidNegativeResult() { + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/iccid/$fakeIccid2") + .request(MediaType.APPLICATION_JSON) + .get() + assertEquals(404, response.status) + verify(dao).getSimProfileByIccid(fakeIccid2) + } + + @Test + fun testFindByImsiPositiveResult() { + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/imsi/$fakeImsi1") + .request(MediaType.APPLICATION_JSON) + .get() + assertEquals(200, response.status) + + val simEntry = response.readEntity(SimEntry::class.java) + assertNotNull(simEntry) + assertEquals(fakeSimEntryWithoutMsisdn, simEntry) + verify(dao).getSimProfileByImsi(fakeImsi1) + } + + + @Test + fun testFindByImsiNegativeResult() { + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/imsi/$fakeImsi2") + .request(MediaType.APPLICATION_JSON) + .get() + assertEquals(404, response.status) + verify(dao).getSimProfileByImsi(fakeImsi2) + } + + @Test + fun testFindByMsisdnPositiveResult() { + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/msisdn/$fakeMsisdn1") + .request(MediaType.APPLICATION_JSON) + .get() + assertEquals(200, response.status) + + val simEntry = response.readEntity(SimEntry::class.java) + assertNotNull(simEntry) + assertEquals(fakeSimEntryWithMsisdn, simEntry) + verify(dao).getSimProfileByMsisdn(fakeMsisdn1) + } + + @Test + fun testFindByMsisdnNegativeResult() { + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/msisdn/$fakeMsisdn2") + .request(MediaType.APPLICATION_JSON) + .get() + assertEquals(404, response.status) + verify(dao).getSimProfileByMsisdn(fakeMsisdn2) + } + + + @Test + fun testActivateEsim() { + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/esim") + .request(MediaType.APPLICATION_JSON) + .get() + assertEquals(200, response.status) + + val simEntry = response.readEntity(SimEntry::class.java) + assertNotNull(simEntry) + + verify(dao).getHssEntryByName(fakeHlr) + verify(config).getProfileForPhoneType(fakePhoneType) + verify(dao).findNextReadyToUseSimProfileForHss(hssEntry.id, fakeProfile) + verify(dao).setProvisionState(simEntry.id!!, ProvisionState.PROVISIONED) + } + + + @Test + @Ignore + fun testImport() { + org.mockito.Mockito.`when`(dao.findSimVendorForHssPermissions(1L, 1L)) + .thenReturn(listOf(0L).right()) + org.mockito.Mockito.`when`(dao.simVendorIsPermittedForHlr(1L, 1L)) + .thenReturn(true.right()) + + val sampleCsvIinput = """ + ICCID, IMSI, MSISDN, PIN1, PIN2, PUK1, PUK2, PROFILE + 123123, 123123, 4790000001, 1233, 1233, 1233, 1233, PROFILE_1 + 123123, 123123, 4790000002, 1233, 1233, 1233, 1233, PROFILE_1 + 123123, 123123, 4790000003, 1233, 1233, 1233, 1233, PROFILE_1 + 123123, 123123, 4790000004, 1233, 1233, 1233, 1233, PROFILE_1 + """.trimIndent() + val data = ByteArrayInputStream(sampleCsvIinput.toByteArray(Charsets.UTF_8)) + + // XXX For some reason this mock fails to match... + org.mockito.Mockito.`when`(dao.importSims("importer", 1L, 1L, data)) + .thenReturn(SimImportBatch( + id = 0L, + status = "SUCCESS", + size = 4L, + hssId = 1L, + profileVendorId = 1L, + importer = "Testroutine", + endedAt = 999L).right()) + + val response = RULE.target("/ostelco/sim-inventory/$fakeHlr/import-batch/profilevendor/$fakeProfileVendor") + .request(MediaType.APPLICATION_JSON) + .put(Entity.entity(sampleCsvIinput, MediaType.TEXT_PLAIN)) + assertEquals(200, response.status) + + val simEntry = response.readEntity(SimImportBatch::class.java) + assertNotNull(simEntry) + } +} diff --git a/sim-administration/simmanager/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sim-administration/simmanager/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..1f0955d45 --- /dev/null +++ b/sim-administration/simmanager/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/sim-administration/simmanager/ssl-config.yml b/sim-administration/simmanager/ssl-config.yml new file mode 100644 index 000000000..99ad6271e --- /dev/null +++ b/sim-administration/simmanager/ssl-config.yml @@ -0,0 +1,60 @@ +logging: + level: INFO + loggers: + org.ostelco: DEBUG + +# Database settings +database: + # the name of your JDBC driver + driverClass: org.sqlite.JDBC + + # the JDBC URL + url: jdbc:sqlite:sim_inventory.db + +httpClient: + + # The minimum number of threads to use for asynchronous calls. + minThreads: 1 + + # The maximum number of threads to use for asynchronous calls. + maxThreads: 128 + + # If true, the client will automatically decode response entities + # with gzip content encoding. + gzipEnabled: true + + # If true, the client will encode request entities with gzip + # content encoding. (Requires gzipEnabled to be true). + gzipEnabledForRequests: true + + +server: + applicationConnectors: + - type: https + port: 8443 + keyStorePath: KeyStore.jks + keyStorePassword: secxret + keyStoreType: JKS + keyStoreProvider: + trustStorePath: TrustStore.jks + trustStorePassword: secret + trustStoreType: JKS + trustStoreProvider: + keyManagerPassword: changeit + needClientAuth: false + wantClientAuth: + certAlias: + crlPath: /path/to/file + enableCRLDP: false + enableOCSP: false + maxCertPathLength: (unlimited) + ocspResponderUrl: (none) + jceProvider: (none) + validateCerts: false + validatePeers: false + supportedProtocols: (JVM default) + excludedProtocols: [SSL, SSLv2, SSLv2Hello, SSLv3] # (Jetty's default) + supportedCipherSuites: (JVM default) + excludedCipherSuites: [.*_(MD5|SHA|SHA1)$] # (Jetty's default) + allowRenegotiation: true + endpointIdentificationAlgorithm: (none) \ No newline at end of file diff --git a/sim-administration/simmanager/update-openapi-spec.sh b/sim-administration/simmanager/update-openapi-spec.sh new file mode 100755 index 000000000..a06e4fd28 --- /dev/null +++ b/sim-administration/simmanager/update-openapi-spec.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +DESTINATION=resources/openapi-sim-inventory--spec.yaml + +curl http://localhost:8080/openapi.yaml > $DESTINATION + + diff --git a/sim-administration/sm-dp-plus/README.md b/sim-administration/sm-dp-plus/README.md new file mode 100644 index 000000000..976a59667 --- /dev/null +++ b/sim-administration/sm-dp-plus/README.md @@ -0,0 +1,10 @@ +# Testing the server using a client certificate + + + + curl -k --insecure -vvvv --request GET --cert ../certificate-authority-simulated/crypto-artefacts/sm-dp-plus/ck.crt:superSecreet --key ../certificate-authority-simulated/crypto-artef/sm-dp-plus/ck.key "https://localhost:8443/ping" + + + +https://developer.okta.com/blog/2015/12/02/tls-client-authentication-for-services + diff --git a/sim-administration/sm-dp-plus/build.gradle b/sim-administration/sm-dp-plus/build.gradle new file mode 100644 index 000000000..81d2d0e1e --- /dev/null +++ b/sim-administration/sm-dp-plus/build.gradle @@ -0,0 +1,50 @@ +plugins { + id "org.jetbrains.kotlin.jvm" + id "application" + id "com.github.johnrengelman.shadow" version "5.0.0" +} + +dependencies { + implementation project(":sim-administration:jersey-json-schema-validator") + implementation project(":sim-administration:simcard-utils") + implementation project(":sim-administration:es2plus4dropwizard") + implementation project(":sim-administration:ostelco-dropwizard-utils") + + compile group: 'javax.activation', name: 'javax.activation-api', version: '1.2.0' + + // https://mvnrepository.com/artifact/org.conscrypt/conscrypt-openjdk + // XXX Delete next line? + compile group: 'org.conscrypt', name: 'conscrypt-openjdk', version: '1.4.2' + + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + + implementation "io.dropwizard:dropwizard-core:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-auth:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" + implementation "io.dropwizard:dropwizard-jdbi:$dropwizardVersion" + + implementation 'org.conscrypt:conscrypt-openjdk-uber:1.4.2' + + implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" + implementation "org.apache.commons:commons-csv:1.6" + + testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } +} + +shadowJar { + mainClassName = "org.ostelco.simcards.smdpplus.SmDpPlusApplication" + mergeServiceFiles() + classifier = "uber" + version = null +} + +apply from: '../../gradle/jacoco.gradle' \ No newline at end of file diff --git a/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SimCardBatchDescriptionReader.kt b/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SimCardBatchDescriptionReader.kt new file mode 100644 index 000000000..56ddd2ecf --- /dev/null +++ b/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SimCardBatchDescriptionReader.kt @@ -0,0 +1,68 @@ +package org.ostelco.simcards.smdpplus + +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVParser +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.nio.charset.Charset +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.atomic.AtomicLong + + +/** + * Read a CSV input stream containing simulated input to an SM-DP+, with columns + * ICCID, IMSI and Profile. Return an iterator over SmDpSimEntry instances. + */ +class SmDpSimEntryIterator(csvInputStream: InputStream) : Iterator { + + private var count = AtomicLong(0) + + private val values = ConcurrentLinkedDeque() + + init { + + val csvFileFormat = CSVFormat.DEFAULT + .withQuote(null) + .withFirstRecordAsHeader() + .withIgnoreEmptyLines(true) + .withTrim() + .withDelimiter(',') + + BufferedReader(InputStreamReader(csvInputStream, Charset.forName( + "ISO-8859-1"))).use { reader -> + CSVParser(reader, csvFileFormat).use { csvParser -> + for (record in csvParser) { + + val iccid = record.get("ICCID") + val imsi = record.get("IMSI") + val profile = record.get("PROFILE") + + + val value = SmDpSimEntry( + iccid = iccid, + imsi = imsi, + profile = profile + ) + + values.add(value) + count.incrementAndGet() + } + } + } + } + + /** + * Returns the next element in the iteration. + */ + override operator fun next(): SmDpSimEntry { + return values.removeLast() + } + + /** + * Returns `true` if the iteration has more elements. + */ + override operator fun hasNext(): Boolean { + return !values.isEmpty() + } +} \ No newline at end of file diff --git a/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt b/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt new file mode 100644 index 000000000..d058f5ab2 --- /dev/null +++ b/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt @@ -0,0 +1,320 @@ +package org.ostelco.simcards.smdpplus + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import org.apache.http.client.HttpClient +import org.ostelco.dropwizardutils.CertAuthConfig +import org.ostelco.dropwizardutils.CertificateAuthorizationFilter +import org.ostelco.dropwizardutils.OpenapiResourceAdder.Companion.addOpenapiResourceToJerseyEnv +import org.ostelco.dropwizardutils.OpenapiResourceAdderConfig +import org.ostelco.dropwizardutils.RBACService +import org.ostelco.dropwizardutils.RolesConfig +import org.ostelco.sim.es2plus.ES2PlusClient +import org.ostelco.sim.es2plus.ES2PlusIncomingHeadersFilter.Companion.addEs2PlusDefaultFiltersAndInterceptors +import org.ostelco.sim.es2plus.Es2ConfirmOrderResponse +import org.ostelco.sim.es2plus.Es2DownloadOrderResponse +import org.ostelco.sim.es2plus.EsTwoPlusConfig +import org.ostelco.sim.es2plus.SmDpPlusServerResource +import org.ostelco.sim.es2plus.SmDpPlusService +import org.ostelco.sim.es2plus.eS2SuccessResponseHeader +import org.slf4j.LoggerFactory +import java.io.FileInputStream +import javax.validation.Valid +import javax.validation.constraints.NotNull + + +/** + * NOTE: This is not a proper SM-DP+ application, it is a test fixture + * to be used when accpetance-testing the sim administration application. + * + * The intent of the SmDpPlusApplication is to be run in Docker Compose, + * to serve a few simple ES2+ commands, and to do so consistently, and to + * report back to the sim administration application via ES2+ callback, as to + * exercise that part of the protocol as well. + * + * In no shape or form is this intended to be a proper SmDpPlus application. It + * does not store sim profiles, it does not talk ES9+ or ES8+ or indeed do + * any of the things that would be useful for serving actual eSIM profiles. + * + * With those caveats in mind, let's go on to the important task of making a simplified + * SM-DP+ that can serve as a test fixture :-) + */ +class SmDpPlusApplication : Application() { + + override fun getName(): String { + return "SM-DP+ implementation (partial, only for testing of sim admin service)" + } + + override fun initialize(bootstrap: Bootstrap) { + // TODO: application initialization + } + + private lateinit var httpClient: HttpClient + + private lateinit var es2plusClient: ES2PlusClient + + override fun run(config: SmDpPlusAppConfiguration, + env: Environment) { + + val jerseyEnvironment = env.jersey() + + addOpenapiResourceToJerseyEnv(jerseyEnvironment, config.openApi) + addEs2PlusDefaultFiltersAndInterceptors(jerseyEnvironment) + + val simEntriesIterator = SmDpSimEntryIterator(FileInputStream(config.simBatchData)) + val smdpPlusService: SmDpPlusService = SmDpPlusEmulator(simEntriesIterator) + + jerseyEnvironment.register(SmDpPlusServerResource( + smDpPlus = smdpPlusService)) + jerseyEnvironment.register(CertificateAuthorizationFilter(RBACService( + rolesConfig = config.rolesConfig, + certConfig = config.certConfig))) + + + jerseyEnvironment.register(CertificateAuthorizationFilter( + RBACService(rolesConfig = config.rolesConfig, + certConfig = config.certConfig))) + + this.httpClient = HttpClientBuilder(env).using(config.httpClientConfiguration).build(name) + this.es2plusClient = ES2PlusClient( + requesterId = config.es2plusConfig.requesterId, + host = config.es2plusConfig.host, + port = config.es2plusConfig.port, + httpClient = httpClient) + } +} + +/** + * A very reduced functionality SmDpPlus, essentially handling only + * happy day scenarios, and not particulary efficient, and in-memory + * only etc. + */ +class SmDpPlusEmulator(incomingEntries: Iterator) : SmDpPlusService { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Global lock, just in case. + */ + private val entriesLock = Object() + + private val entries: MutableSet = mutableSetOf() + private val entriesByIccid = mutableMapOf() + private val entriesByImsi = mutableMapOf() + private val entriesByProfile = mutableMapOf>() + + init { + incomingEntries.forEach { + entries.add(it) + entriesByIccid[it.iccid] = it + entriesByImsi[it.imsi] = it + val entriesForProfile: MutableSet + if (!entriesByProfile.containsKey(it.profile)) { + entriesForProfile = mutableSetOf() + entriesByProfile[it.profile] = entriesForProfile + } else { + entriesForProfile = entriesByProfile[it.profile]!! + } + entriesForProfile.add(it) + } + + log.info("Just read ${entries.size} SIM entries.") + } + + // TODO; What about the reservation flag? + override fun downloadOrder(eid: String?, iccid: String?, profileType: String?): Es2DownloadOrderResponse { + synchronized(entriesLock) { + val entry: SmDpSimEntry = findMatchingFreeProfile(iccid, profileType) + ?: throw SmDpPlusException("Could not find download order matching criteria") + + // If an EID is known, then mark this as the IED associated + // with the entry. + if (eid != null) { + entry.eid = eid + } + + // Then mark the entry as allocated and return the corresponding ICCID. + entry.allocated = true + + // Finally return the ICCID uniquely identifying the profile instance. + return Es2DownloadOrderResponse(eS2SuccessResponseHeader(), + iccid = entry.iccid) + } + } + + /** + * Find a free profile that either matches both profileStatusList and profile type (if profileStatusList != null), + * or just profile type (if profileStatusList == null). Throw runtime exception if parameter + * errors are discovered, but return null if no matching profile is found. + */ + private fun findMatchingFreeProfile(iccid: String?, profileType: String?): SmDpSimEntry? { + return if (iccid != null) { + findUnallocatedByIccidAndProfileType(iccid, profileType) + } else if (profileType == null) { + throw RuntimeException("No profileStatusList, no profile type, so don't know how to allocate sim entry") + } else if (!entriesByProfile.containsKey(profileType)) { + throw SmDpPlusException("Unknown profile type $profileType") + } else { + allocateByProfile(profileType) + } + } + + /** + * Find an allocatable profile by profile type. If a free and matching profile can be found. If not, then + * return null. + */ + private fun allocateByProfile(profileType: String): SmDpSimEntry? { + val entriesForProfile = entriesByProfile[profileType] ?: return null + return entriesForProfile.find { !it.allocated } + } + + /** + * Allocate by ICCID, but only do so if the profileStatusList exists, and the + * profile associated with that ICCID matches the expected profile type + * (if not null, null will match anything). + */ + private fun findUnallocatedByIccidAndProfileType(iccid: String, profileType: String?): SmDpSimEntry { + if (!entriesByIccid.containsKey(iccid)) { + throw RuntimeException("Attempt to allocate nonexisting profileStatusList $iccid") + } + + val entry = entriesByIccid[iccid]!! + + if (entry.allocated) { + throw SmDpPlusException("Attempt to download an already allocated SIM entry") + } + + if (profileType != null) { + if (entry.profile != profileType) { + throw SmDpPlusException("Profile of profileStatusList = $iccid is ${entry.profile}, not $profileType") + } + } + return entry + } + + /** + * Generate a fixed corresponding EID based on ICCID. + * XXX Whoot? + **/ + private fun getEidFromIccid(iccid: String): String? = if (iccid.isNotEmpty()) + "01010101010101010101" + iccid.takeLast(12) + else + null + + override fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag: Boolean): Es2ConfirmOrderResponse { + + if (iccid == null) { + throw RuntimeException("No ICCD, cannot confirm order") + } + if (!entriesByIccid.containsKey(iccid)) { + throw RuntimeException("Attempt to allocate nonexisting profileStatusList $iccid") + } + val entry = entriesByIccid[iccid]!! + + + if (smdsAddress != null) { + entry.smdsAddress = smdsAddress + } + + if (machingId != null) { + entry.machingId = confirmationCode + } else { + entry.machingId = "0123-ABC-KGBC-IAMOS-SAD0" /// XXX This is obviously bogus code! + } + + entry.released = releaseFlag + + if (confirmationCode != null) { + entry.confirmationCode = confirmationCode + } + + val eidReturned = if (eid.isNullOrEmpty()) + getEidFromIccid(iccid) + else + eid + + return Es2ConfirmOrderResponse(eS2SuccessResponseHeader(), + eid = eidReturned!!, + smdsAddress = entry.smdsAddress, + matchingId = entry.machingId) + } + + override fun cancelOrder(eid: String?, iccid: String?, matchingId: String?, finalProfileStatusIndicator: String?) { + TODO("not implemented") + } + + override fun releaseProfile(iccid: String) { + TODO("not implemented") + } +} + +/** + * Thrown when an non-recoverable error is encountered byt he sm-dp+ implementation. + */ +class SmDpPlusException(message: String) : Exception(message) + + +/** + * Configuration class for SM-DP+ emulator. + */ +class SmDpPlusAppConfiguration : Configuration() { + + /** + * Configuring how the Open API representation of the + * served resources will be presenting itself (owner, + * license etc.) + */ + @Valid + @NotNull + @JsonProperty("es2plusClient") + var es2plusConfig = EsTwoPlusConfig() + + /** + * Configuring how the Open API representation of the + * served resources will be presenting itself (owner, + * license etc.) + */ + @Valid + @NotNull + @JsonProperty("openApi") + var openApi = OpenapiResourceAdderConfig() + + /** + * Path to file containing simulated SIM data. + */ + @Valid + @NotNull + @JsonProperty("simBatchData") + var simBatchData: String = "" + + /** + * The httpClient we use to connect to other services, including + * ES2+ services + */ + @Valid + @NotNull + @JsonProperty("httpClient") + var httpClientConfiguration = HttpClientConfiguration() + + /** + * Declaring the mapping between users and certificates, also + * which roles the users are assigned to. + */ + @Valid + @JsonProperty("certAuth") + @NotNull + var certConfig = CertAuthConfig() + + /** + * Declaring which roles we will permit + */ + @Valid + @JsonProperty("roles") + @NotNull + var rolesConfig = RolesConfig() +} diff --git a/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SmDpSimEntry.kt b/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SmDpSimEntry.kt new file mode 100644 index 000000000..eddd8a748 --- /dev/null +++ b/sim-administration/sm-dp-plus/src/main/java/org/ostelco/simcards/smdpplus/SmDpSimEntry.kt @@ -0,0 +1,17 @@ +package org.ostelco.simcards.smdpplus + +class SmDpSimEntry (val iccid: String, + val imsi: String, + val profile: String) { + var allocated: Boolean = false + var eid: String? = null + var released: Boolean = false + var confirmationCode: String? = null + var machingId: String? = null + var smdsAddress :String? = null + + + fun clone(): SmDpSimEntry { + return SmDpSimEntry(iccid = iccid, imsi=imsi, profile=profile) + } +} diff --git a/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/EncryptedEs2PlusTest.kt b/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/EncryptedEs2PlusTest.kt new file mode 100644 index 000000000..dbaa0f87e --- /dev/null +++ b/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/EncryptedEs2PlusTest.kt @@ -0,0 +1,62 @@ +package org.ostelco.simcards.smdpplus + +import io.dropwizard.testing.DropwizardTestSupport +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.ostelco.sim.es2plus.ES2PlusClient +import org.ostelco.sim.es2plus.FunctionExecutionStatusType + +class EncryptedEs2PlusTest { + + @Before + fun setUp() { + SUPPORT.before() + } + + @After + fun tearDown() { + SUPPORT.after() + } + + /** + * These are the two scenarios that needs to be run in sequence from a + * sim-adminstrator, in pretty much the same way as it being run here. + */ + /* Disabled as remote is not configured to support SSL. */ + @Test + @Ignore + fun handleHappyDayScenario() { + val client: ES2PlusClient = + SUPPORT.getApplication().es2plusClient + val eid = "12345678980123456789012345678901" + val iccid = "8901000000000000001" + val downloadResponse = client.downloadOrder(eid = eid, iccid = iccid, profileType = "FooTel_STD") + + assertEquals(FunctionExecutionStatusType.ExecutedSuccess, downloadResponse.header.functionExecutionStatus.status) + assertEquals(iccid, downloadResponse.iccid) + + + val confirmResponse = + client.confirmOrder( + eid = eid, + iccid = iccid, + releaseFlag = true) + + // This happens to be the matching ID used for everything in the test application, not a good + // assumption for production code, but this isn't that. + val matchingId = "0123-ABCD-KGBC-IAMSO-SAD0" + assertEquals(FunctionExecutionStatusType.ExecutedSuccess, confirmResponse.header.functionExecutionStatus.status) + assertEquals(eid, confirmResponse.eid) + assertEquals(matchingId, confirmResponse.matchingId) + } + + companion object { + val SUPPORT = DropwizardTestSupport( + SmDpPlusApplication::class.java, + "src/test/resources/config.yml" + ) + } +} \ No newline at end of file diff --git a/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/SimCardBatchDescriptionReaderTest.kt b/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/SimCardBatchDescriptionReaderTest.kt new file mode 100644 index 000000000..e6e98aec4 --- /dev/null +++ b/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/SimCardBatchDescriptionReaderTest.kt @@ -0,0 +1,82 @@ +package org.ostelco.simcards.smdpplus + +import io.dropwizard.testing.ResourceHelpers +import org.junit.Assert.assertEquals +import org.junit.Ignore +import org.junit.Test +import org.ostelco.simcards.IccidBasis +import java.io.FileInputStream +import java.io.PrintWriter + + +class SimCardBatchDescriptorReaderTest { + + private val smdpInputCsvPath: String? = + ResourceHelpers.resourceFilePath("fixtures/sample-sim-batch-for-sm-dp+.csv") + + @Test + fun testReadingListOfEntriesFromFile() { + var foo = 0 + SmDpSimEntryIterator(FileInputStream(smdpInputCsvPath)).forEach { _ -> foo++ } + assertEquals(100, foo) + } + + /** + * This is not a test, it is utility code that is used to generate the input file + * for for sm-dp+ test article, so ordinarily this "test" should be ignored, + * but when new testdata needs to be generated, it should be un-ignored, and run, + * then the generated data should be copied to wherever it should be stored, and + * ordinary testing can continue. + * + * XXX Take this out of the test code, make it into an utility app that can + * be easily run from the command line. + */ + @Test + @Ignore + fun generateSmdpInputCsv() { + val mcc = 310 + val mnc = 150 + val imsiGen = ImsiGenerator(mcc = mcc, mnc = mnc, msinStart = 0 ) + val iccidGen = IccidGenerator(startSerialNum = 0) + val profileName = "FooTel_STD" + + val writer = PrintWriter("sample-sim-batch-for-sm-dp+.csv", "UTF-8") + writer.println("IMSI, ICCID, PROFILE") + for (i in 1..100) { + val imsi = imsiGen.next() + val iccid = iccidGen.next() + writer.println("%s,%s,%s".format(imsi, iccid, profileName)) + } + writer.close() + } +} + +class ImsiGenerator(val mcc : Int, val mnc: Int, val msinStart : Int) : Iterator { + + private var msin = msinStart + + @Throws(NoSuchElementException::class) + override fun next(): String { + return "%03d%02d%010d".format(mcc, mnc, msin++) + } + + override fun hasNext(): Boolean { + return true + } +} + +class IccidGenerator(val startSerialNum: Int = 0) : Iterator { + + private var serialNumber:Int = startSerialNum + /** + * Returns the next element in the iteration. + */ + @Throws(NoSuchElementException::class) + override fun next(): String { + return IccidBasis(serialNumber = serialNumber++).asIccid() + } + + override fun hasNext(): Boolean { + return true + } +} \ No newline at end of file diff --git a/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt b/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt new file mode 100644 index 000000000..7b8629b4e --- /dev/null +++ b/sim-administration/sm-dp-plus/src/test/java/org/ostelco/simcards/smdpplus/SmDpPlusApplication.kt @@ -0,0 +1,320 @@ +package org.ostelco.simcards.smdpplus + +import com.fasterxml.jackson.annotation.JsonProperty +import io.dropwizard.Application +import io.dropwizard.Configuration +import io.dropwizard.client.HttpClientBuilder +import io.dropwizard.client.HttpClientConfiguration +import io.dropwizard.setup.Bootstrap +import io.dropwizard.setup.Environment +import org.apache.http.client.HttpClient +import org.ostelco.dropwizardutils.CertAuthConfig +import org.ostelco.dropwizardutils.CertificateAuthorizationFilter +import org.ostelco.dropwizardutils.OpenapiResourceAdder.Companion.addOpenapiResourceToJerseyEnv +import org.ostelco.dropwizardutils.OpenapiResourceAdderConfig +import org.ostelco.dropwizardutils.RBACService +import org.ostelco.dropwizardutils.RolesConfig +import org.ostelco.sim.es2plus.ES2PlusClient +import org.ostelco.sim.es2plus.ES2PlusIncomingHeadersFilter.Companion.addEs2PlusDefaultFiltersAndInterceptors +import org.ostelco.sim.es2plus.Es2ConfirmOrderResponse +import org.ostelco.sim.es2plus.Es2DownloadOrderResponse +import org.ostelco.sim.es2plus.EsTwoPlusConfig +import org.ostelco.sim.es2plus.SmDpPlusServerResource +import org.ostelco.sim.es2plus.SmDpPlusService +import org.ostelco.sim.es2plus.eS2SuccessResponseHeader +import org.slf4j.LoggerFactory +import java.io.FileInputStream +import javax.validation.Valid +import javax.validation.constraints.NotNull + + +/** + * NOTE: This is not a proper SM-DP+ application, it is a test fixture + * to be used when accpetance-testing the sim administration application. + * + * The intent of the SmDpPlusApplication is to be run in Docker Compose, + * to serve a few simple ES2+ commands, and to do so consistently, and to + * report back to the sim administration application via ES2+ callback, as to + * exercise that part of the protocol as well. + * + * In no shape or form is this intended to be a proper SmDpPlus application. It + * does not store sim profiles, it does not talk ES9+ or ES8+ or indeed do + * any of the things that would be useful for serving actual eSIM profiles. + * + * With those caveats in mind, let's go on to the important task of making a simplified + * SM-DP+ that can serve as a test fixture :-) + */ +class SmDpPlusApplication : Application() { + + override fun getName(): String { + return "SM-DP+ implementation (partial, only for testing of sim admin service)" + } + + override fun initialize(bootstrap: Bootstrap) { + // TODO: application initialization + } + + private lateinit var httpClient: HttpClient + + lateinit var es2plusClient: ES2PlusClient + + override fun run(config: SmDpPlusAppConfiguration, + env: Environment) { + + val jerseyEnvironment = env.jersey() + + addOpenapiResourceToJerseyEnv(jerseyEnvironment, config.openApi) + addEs2PlusDefaultFiltersAndInterceptors(jerseyEnvironment) + + val simEntriesIterator = SmDpSimEntryIterator(FileInputStream(config.simBatchData)) + val smdpPlusService: SmDpPlusService = SmDpPlusEmulator(simEntriesIterator) + + jerseyEnvironment.register(SmDpPlusServerResource( + smDpPlus = smdpPlusService)) + jerseyEnvironment.register(CertificateAuthorizationFilter(RBACService( + rolesConfig = config.rolesConfig, + certConfig = config.certConfig))) + + + jerseyEnvironment.register(CertificateAuthorizationFilter( + RBACService(rolesConfig = config.rolesConfig, + certConfig = config.certConfig))) + + this.httpClient = HttpClientBuilder(env).using(config.httpClientConfiguration).build(name) + this.es2plusClient = ES2PlusClient( + requesterId = config.es2plusConfig.requesterId, + host = config.es2plusConfig.host, + port = config.es2plusConfig.port, + httpClient = httpClient) + } +} + +/** + * A very reduced functionality SmDpPlus, essentially handling only + * happy day scenarios, and not particulary efficient, and in-memory + * only etc. + */ +class SmDpPlusEmulator(incomingEntries: Iterator) : SmDpPlusService { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Global lock, just in case. + */ + private val entriesLock = Object() + + private val entries: MutableSet = mutableSetOf() + private val entriesByIccid = mutableMapOf() + private val entriesByImsi = mutableMapOf() + private val entriesByProfile = mutableMapOf>() + + init { + incomingEntries.forEach { + entries.add(it) + entriesByIccid[it.iccid] = it + entriesByImsi[it.imsi] = it + val entriesForProfile: MutableSet + if (!entriesByProfile.containsKey(it.profile)) { + entriesForProfile = mutableSetOf() + entriesByProfile[it.profile] = entriesForProfile + } else { + entriesForProfile = entriesByProfile[it.profile]!! + } + entriesForProfile.add(it) + } + + log.info("Just read ${entries.size} SIM entries.") + } + + // TODO; What about the reservation flag? + override fun downloadOrder(eid: String?, iccid: String?, profileType: String?): Es2DownloadOrderResponse { + synchronized(entriesLock) { + val entry: SmDpSimEntry = findMatchingFreeProfile(iccid, profileType) + ?: throw SmDpPlusException("Could not find download order matching criteria") + + // If an EID is known, then mark this as the IED associated + // with the entry. + if (eid != null) { + entry.eid = eid + } + + // Then mark the entry as allocated and return the corresponding ICCID. + entry.allocated = true + + // Finally return the ICCID uniquely identifying the profile instance. + return Es2DownloadOrderResponse(eS2SuccessResponseHeader(), + iccid = entry.iccid) + } + } + + /** + * Find a free profile that either matches both profileStatusList and profile type (if profileStatusList != null), + * or just profile type (if profileStatusList == null). Throw runtime exception if parameter + * errors are discovered, but return null if no matching profile is found. + */ + private fun findMatchingFreeProfile(iccid: String?, profileType: String?): SmDpSimEntry? { + return if (iccid != null) { + findUnallocatedByIccidAndProfileType(iccid, profileType) + } else if (profileType == null) { + throw RuntimeException("No profileStatusList, no profile type, so don't know how to allocate sim entry") + } else if (!entriesByProfile.containsKey(profileType)) { + throw SmDpPlusException("Unknown profile type $profileType") + } else { + allocateByProfile(profileType) + } + } + + /** + * Find an allocatable profile by profile type. If a free and matching profile can be found. If not, then + * return null. + */ + private fun allocateByProfile(profileType: String): SmDpSimEntry? { + val entriesForProfile = entriesByProfile[profileType] ?: return null + return entriesForProfile.find { !it.allocated } + } + + /** + * Allocate by ICCID, but only do so if the profileStatusList exists, and the + * profile associated with that ICCID matches the expected profile type + * (if not null, null will match anything). + */ + private fun findUnallocatedByIccidAndProfileType(iccid: String, profileType: String?): SmDpSimEntry { + if (!entriesByIccid.containsKey(iccid)) { + throw RuntimeException("Attempt to allocate nonexisting profileStatusList $iccid") + } + + val entry = entriesByIccid[iccid]!! + + if (entry.allocated) { + throw SmDpPlusException("Attempt to download an already allocated SIM entry") + } + + if (profileType != null) { + if (entry.profile != profileType) { + throw SmDpPlusException("Profile of profileStatusList = $iccid is ${entry.profile}, not $profileType") + } + } + return entry + } + + /** + * Generate a fixed corresponding EID based on ICCID. + * XXX Whoot? + **/ + private fun getEidFromIccid(iccid: String): String? = if (iccid.isNotEmpty()) + "01010101010101010101" + iccid.takeLast(12) + else + null + + override fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag: Boolean): Es2ConfirmOrderResponse { + + if (iccid == null) { + throw RuntimeException("No ICCD, cannot confirm order") + } + if (!entriesByIccid.containsKey(iccid)) { + throw RuntimeException("Attempt to allocate nonexisting profileStatusList $iccid") + } + val entry = entriesByIccid[iccid]!! + + + if (smdsAddress != null) { + entry.smdsAddress = smdsAddress + } + + if (machingId != null) { + entry.machingId = confirmationCode + } else { + entry.machingId = "0123-ABC-KGBC-IAMOS-SAD0" /// XXX This is obviously bogus code! + } + + entry.released = releaseFlag + + if (confirmationCode != null) { + entry.confirmationCode = confirmationCode + } + + val eidReturned = if (eid.isNullOrEmpty()) + getEidFromIccid(iccid) + else + eid + + return Es2ConfirmOrderResponse(eS2SuccessResponseHeader(), + eid = eidReturned!!, + smdsAddress = entry.smdsAddress, + matchingId = entry.machingId) + } + + override fun cancelOrder(eid: String?, iccid: String?, matchingId: String?, finalProfileStatusIndicator: String?) { + TODO("not implemented") + } + + override fun releaseProfile(iccid: String) { + TODO("not implemented") + } +} + +/** + * Thrown when an non-recoverable error is encountered byt he sm-dp+ implementation. + */ +class SmDpPlusException(message: String) : Exception(message) + + +/** + * Configuration class for SM-DP+ emulator. + */ +class SmDpPlusAppConfiguration : Configuration() { + + /** + * Configuring how the Open API representation of the + * served resources will be presenting itself (owner, + * license etc.) + */ + @Valid + @NotNull + @JsonProperty("es2plusClient") + var es2plusConfig = EsTwoPlusConfig() + + /** + * Configuring how the Open API representation of the + * served resources will be presenting itself (owner, + * license etc.) + */ + @Valid + @NotNull + @JsonProperty("openApi") + var openApi = OpenapiResourceAdderConfig() + + /** + * Path to file containing simulated SIM data. + */ + @Valid + @NotNull + @JsonProperty("simBatchData") + var simBatchData: String = "" + + /** + * The httpClient we use to connect to other services, including + * ES2+ services + */ + @Valid + @NotNull + @JsonProperty("httpClient") + var httpClientConfiguration = HttpClientConfiguration() + + /** + * Declaring the mapping between users and certificates, also + * which roles the users are assigned to. + */ + @Valid + @JsonProperty("certAuth") + @NotNull + var certConfig = CertAuthConfig() + + /** + * Declaring which roles we will permit + */ + @Valid + @JsonProperty("roles") + @NotNull + var rolesConfig = RolesConfig() +} diff --git a/sim-administration/sm-dp-plus/src/test/resources/config.yml b/sim-administration/sm-dp-plus/src/test/resources/config.yml new file mode 100644 index 000000000..e0db519a8 --- /dev/null +++ b/sim-administration/sm-dp-plus/src/test/resources/config.yml @@ -0,0 +1,72 @@ +logging: + level: INFO + +simBatchData: src/test/resources/fixtures/sample-sim-batch-for-sm-dp+.csv + +openApi: + name: SM-DP-emulator + description: Test fixture simulating ES2+ interactions of an SM-DP+ + termsOfService: http://example.org + contactEmail: rmz@telenordigital.com + resourcePackage: org.ostelco + +server: + adminMinThreads: 1 + adminMaxThreads: 64 + adminContextPath: / + applicationContextPath: / + applicationConnectors: + - type: http + port: 8080 + - type: https + port: 8443 + # Enabling conscrypt blows the whole thing up, so don't do that. + #jceProvider: Conscrypt + keyStoreType: JKS + keyStorePath: src/test/resources/sk_keys.jks + keyStorePassword: superSecreet + validateCerts: false + needClientAuth: true + wantClientAuth: true + supportedProtocols: [TLSv1.1, TLSv1.2, TLSv1.3] + excludedProtocols: [SSLv2Hello, SSLv3] + +httpClient: + timeout: 5000000ms + tls: + # Default is 500 milliseconds, we need more when debugging. + protocol: TLSv1.2 + keyStoreType: JKS + keyStorePath: src/test/resources/sk_keys.jks + keyStorePassword: superSecreet + # validateCerts: false + # keyStorePath: src/test/resources/sk_keys.jks + # keyStorePassword: superSecreet + # keyStoreType: JKS + # trustStorePath: sk_trust.jks + # trustStorePassword: superSecreet + # trustStoreType: JKS + verifyHostname: false + trustSelfSignedCertificates: true + +# CN=*.not-really-ostelco.org, O=Not really SMDP org, L=Oslo, ST=Oslo, C=NO +certAuth: + certAuths: + - userId: MrFish + country: 'NO' + location: Oslo + state: '' + organization: Not really SMDP org + commonName: '*.not-really-ostelco.org' + roles: + - flyfisher + +roles: + definitions: + - name: flyfisher + description: Obviously just a dummy role + +es2plusClient: + requesterId: abc + host: localhost + port: 8080 \ No newline at end of file diff --git a/sim-administration/sm-dp-plus/src/test/resources/fixtures/sample-sim-batch-for-sm-dp+.csv b/sim-administration/sm-dp-plus/src/test/resources/fixtures/sample-sim-batch-for-sm-dp+.csv new file mode 100644 index 000000000..7110d00c6 --- /dev/null +++ b/sim-administration/sm-dp-plus/src/test/resources/fixtures/sample-sim-batch-for-sm-dp+.csv @@ -0,0 +1,101 @@ +IMSI, ICCID, PROFILE +3101500000000000,8901000000000000001,FooTel_STD +3101500000000001,8901000000000000019,FooTel_STD +3101500000000002,8901000000000000027,FooTel_STD +3101500000000003,8901000000000000035,FooTel_STD +3101500000000004,8901000000000000043,FooTel_STD +3101500000000005,8901000000000000050,FooTel_STD +3101500000000006,8901000000000000068,FooTel_STD +3101500000000007,8901000000000000076,FooTel_STD +3101500000000008,8901000000000000084,FooTel_STD +3101500000000009,8901000000000000092,FooTel_STD +3101500000000010,8901000000000000100,FooTel_STD +3101500000000011,8901000000000000118,FooTel_STD +3101500000000012,8901000000000000126,FooTel_STD +3101500000000013,8901000000000000134,FooTel_STD +3101500000000014,8901000000000000142,FooTel_STD +3101500000000015,8901000000000000159,FooTel_STD +3101500000000016,8901000000000000167,FooTel_STD +3101500000000017,8901000000000000175,FooTel_STD +3101500000000018,8901000000000000183,FooTel_STD +3101500000000019,8901000000000000191,FooTel_STD +3101500000000020,8901000000000000209,FooTel_STD +3101500000000021,8901000000000000217,FooTel_STD +3101500000000022,8901000000000000225,FooTel_STD +3101500000000023,8901000000000000233,FooTel_STD +3101500000000024,8901000000000000241,FooTel_STD +3101500000000025,8901000000000000258,FooTel_STD +3101500000000026,8901000000000000266,FooTel_STD +3101500000000027,8901000000000000274,FooTel_STD +3101500000000028,8901000000000000282,FooTel_STD +3101500000000029,8901000000000000290,FooTel_STD +3101500000000030,8901000000000000308,FooTel_STD +3101500000000031,8901000000000000316,FooTel_STD +3101500000000032,8901000000000000324,FooTel_STD +3101500000000033,8901000000000000332,FooTel_STD +3101500000000034,8901000000000000340,FooTel_STD +3101500000000035,8901000000000000357,FooTel_STD +3101500000000036,8901000000000000365,FooTel_STD +3101500000000037,8901000000000000373,FooTel_STD +3101500000000038,8901000000000000381,FooTel_STD +3101500000000039,8901000000000000399,FooTel_STD +3101500000000040,8901000000000000407,FooTel_STD +3101500000000041,8901000000000000415,FooTel_STD +3101500000000042,8901000000000000423,FooTel_STD +3101500000000043,8901000000000000431,FooTel_STD +3101500000000044,8901000000000000449,FooTel_STD +3101500000000045,8901000000000000456,FooTel_STD +3101500000000046,8901000000000000464,FooTel_STD +3101500000000047,8901000000000000472,FooTel_STD +3101500000000048,8901000000000000480,FooTel_STD +3101500000000049,8901000000000000498,FooTel_STD +3101500000000050,8901000000000000506,FooTel_STD +3101500000000051,8901000000000000514,FooTel_STD +3101500000000052,8901000000000000522,FooTel_STD +3101500000000053,8901000000000000530,FooTel_STD +3101500000000054,8901000000000000548,FooTel_STD +3101500000000055,8901000000000000555,FooTel_STD +3101500000000056,8901000000000000563,FooTel_STD +3101500000000057,8901000000000000571,FooTel_STD +3101500000000058,8901000000000000589,FooTel_STD +3101500000000059,8901000000000000597,FooTel_STD +3101500000000060,8901000000000000605,FooTel_STD +3101500000000061,8901000000000000613,FooTel_STD +3101500000000062,8901000000000000621,FooTel_STD +3101500000000063,8901000000000000639,FooTel_STD +3101500000000064,8901000000000000647,FooTel_STD +3101500000000065,8901000000000000654,FooTel_STD +3101500000000066,8901000000000000662,FooTel_STD +3101500000000067,8901000000000000670,FooTel_STD +3101500000000068,8901000000000000688,FooTel_STD +3101500000000069,8901000000000000696,FooTel_STD +3101500000000070,8901000000000000704,FooTel_STD +3101500000000071,8901000000000000712,FooTel_STD +3101500000000072,8901000000000000720,FooTel_STD +3101500000000073,8901000000000000738,FooTel_STD +3101500000000074,8901000000000000746,FooTel_STD +3101500000000075,8901000000000000753,FooTel_STD +3101500000000076,8901000000000000761,FooTel_STD +3101500000000077,8901000000000000779,FooTel_STD +3101500000000078,8901000000000000787,FooTel_STD +3101500000000079,8901000000000000795,FooTel_STD +3101500000000080,8901000000000000803,FooTel_STD +3101500000000081,8901000000000000811,FooTel_STD +3101500000000082,8901000000000000829,FooTel_STD +3101500000000083,8901000000000000837,FooTel_STD +3101500000000084,8901000000000000845,FooTel_STD +3101500000000085,8901000000000000852,FooTel_STD +3101500000000086,8901000000000000860,FooTel_STD +3101500000000087,8901000000000000878,FooTel_STD +3101500000000088,8901000000000000886,FooTel_STD +3101500000000089,8901000000000000894,FooTel_STD +3101500000000090,8901000000000000902,FooTel_STD +3101500000000091,8901000000000000910,FooTel_STD +3101500000000092,8901000000000000928,FooTel_STD +3101500000000093,8901000000000000936,FooTel_STD +3101500000000094,8901000000000000944,FooTel_STD +3101500000000095,8901000000000000951,FooTel_STD +3101500000000096,8901000000000000969,FooTel_STD +3101500000000097,8901000000000000977,FooTel_STD +3101500000000098,8901000000000000985,FooTel_STD +3101500000000099,8901000000000000993,FooTel_STD diff --git a/sim-administration/sm-dp-plus/src/test/resources/fixtures/sample-sim-csv-file.csv b/sim-administration/sm-dp-plus/src/test/resources/fixtures/sample-sim-csv-file.csv new file mode 100644 index 000000000..1c7a411a9 --- /dev/null +++ b/sim-administration/sm-dp-plus/src/test/resources/fixtures/sample-sim-csv-file.csv @@ -0,0 +1,5 @@ +ICCID, IMSI, PROFILE +123123, 123123, FootelStd +123124, 123124, FootelStd +123125, 123125, FootelStd +123126, 123126, FootelStd diff --git a/sim-administration/sm-dp-plus/src/test/resources/sk_keys.jks b/sim-administration/sm-dp-plus/src/test/resources/sk_keys.jks new file mode 100644 index 000000000..8e2775bf1 Binary files /dev/null and b/sim-administration/sm-dp-plus/src/test/resources/sk_keys.jks differ diff --git a/slack/build.gradle b/slack/build.gradle index 6a80228f2..889988429 100644 --- a/slack/build.gradle +++ b/slack/build.gradle @@ -1,5 +1,5 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "java-library" } @@ -8,9 +8,6 @@ dependencies { implementation "io.dropwizard:dropwizard-client:$dropwizardVersion" - testImplementation "javax.xml.bind:jaxb-api:$jaxbVersion" - testImplementation "javax.activation:activation:$javaxActivationVersion" - testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion" testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5Version" @@ -36,4 +33,6 @@ test { exceptionFormat = 'full' events "PASSED", "FAILED", "SKIPPED" } -} \ No newline at end of file +} + +apply from: '../gradle/jacoco.gradle' \ No newline at end of file diff --git a/slack/src/main/kotlin/org/ostelco/prime/slack/SlackIntegrationModule.kt b/slack/src/main/kotlin/org/ostelco/prime/slack/SlackIntegrationModule.kt index 368016880..ca2e51805 100644 --- a/slack/src/main/kotlin/org/ostelco/prime/slack/SlackIntegrationModule.kt +++ b/slack/src/main/kotlin/org/ostelco/prime/slack/SlackIntegrationModule.kt @@ -19,7 +19,7 @@ class SlackIntegrationModule : PrimeModule { val httpClient = HttpClientBuilder(env) .using(this.httpClientConfiguration) - .build("slack"); + .build("slack") Registry.slackWebHookClient = SlackWebHookClient( webHookUri = this.webHookUri, @@ -39,10 +39,9 @@ object Registry { lateinit var userName: String } -class Config { +data class Config( @JsonProperty("notifications") - lateinit var notificationsConfig: NotificationsConfig -} + val notificationsConfig: NotificationsConfig) class NotificationsConfig { diff --git a/tools/neo4j-admin-tools/build.gradle b/tools/neo4j-admin-tools/build.gradle index 130c3e0af..94d7c9d64 100644 --- a/tools/neo4j-admin-tools/build.gradle +++ b/tools/neo4j-admin-tools/build.gradle @@ -1,12 +1,10 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.2.71" + id "org.jetbrains.kotlin.jvm" id "application" - id "com.github.johnrengelman.shadow" version "4.0.1" + id "com.github.johnrengelman.shadow" version "5.0.0" id "idea" } -ext.neo4jDriverVersion="1.6.3" - dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" @@ -17,10 +15,8 @@ dependencies { } shadowJar { - mainClassName = 'org.ostelco.tools.migration.MainKtKt' + mainClassName = 'org.ostelco.tools.migration.MainKt' mergeServiceFiles() classifier = "uber" version = null -} - -apply from: '../../jacoco.gradle' \ No newline at end of file +} \ No newline at end of file diff --git a/tools/neo4j-admin-tools/docker-compose.backup.yaml b/tools/neo4j-admin-tools/docker-compose.backup.yaml index abe73db72..7198ba5fd 100644 --- a/tools/neo4j-admin-tools/docker-compose.backup.yaml +++ b/tools/neo4j-admin-tools/docker-compose.backup.yaml @@ -3,7 +3,7 @@ version: "3.7" services: neo4j-online-backup: container_name: neo4j-online-backup - image: neo4j:3.4.8-enterprise + image: neo4j:3.4.9-enterprise command: > bin/neo4j-admin backup --backup-dir=/backup_dir diff --git a/tools/neo4j-admin-tools/docker-compose.neo4j.yaml b/tools/neo4j-admin-tools/docker-compose.neo4j.yaml index a8a438294..f408f83f6 100644 --- a/tools/neo4j-admin-tools/docker-compose.neo4j.yaml +++ b/tools/neo4j-admin-tools/docker-compose.neo4j.yaml @@ -3,7 +3,7 @@ version: "3.7" services: neo4j: container_name: "neo4j" - image: neo4j:3.4.8 + image: neo4j:3.4.9 environment: - NEO4J_AUTH=none ports: diff --git a/tools/neo4j-admin-tools/docker-compose.restore.yaml b/tools/neo4j-admin-tools/docker-compose.restore.yaml index 8c9256aed..21a68f69f 100644 --- a/tools/neo4j-admin-tools/docker-compose.restore.yaml +++ b/tools/neo4j-admin-tools/docker-compose.restore.yaml @@ -3,7 +3,7 @@ version: "3.7" services: neo4j-online-restore: container_name: neo4j-online-restore - image: neo4j:3.4.8-enterprise + image: neo4j:3.4.9-enterprise command: > bin/neo4j-admin restore --from=/backup_dir/graph.db-backup diff --git a/tools/neo4j-admin-tools/docker-compose.yaml b/tools/neo4j-admin-tools/docker-compose.yaml index b73d53eed..0d9d30eca 100644 --- a/tools/neo4j-admin-tools/docker-compose.yaml +++ b/tools/neo4j-admin-tools/docker-compose.yaml @@ -3,7 +3,7 @@ version: "3.7" services: neo4j: container_name: "neo4j" - image: neo4j:3.4.8 + image: neo4j:3.4.9 environment: - NEO4J_AUTH=none ports: diff --git a/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/CypherTemplates.kt b/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/CypherTemplates.kt index bb41dcc9a..10fa95059 100644 --- a/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/CypherTemplates.kt +++ b/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/CypherTemplates.kt @@ -1,16 +1,14 @@ package org.ostelco.tools.migration -import org.ostelco.prime.model.Subscriber +import org.ostelco.prime.model.Customer -fun createSubscriber(subscriber: Subscriber) = """ -CREATE(node:Subscriber {id: '${subscriber.email}', - `email`: '${subscriber.email}', - `name`: '${subscriber.name}', - `address`: '${subscriber.address}', - `postCode`: '${subscriber.postCode}', - `city`: '${subscriber.city}', - `country`: '${subscriber.country}'}); +fun createSubscriber(customer: Customer) = """ +CREATE(node:Subscriber {id: '${customer.id}', + `nickname`: '${customer.nickname}' + `contactEmail`: '${customer.contactEmail}', + `analyticsId`: '${customer.analyticsId}', + `referralId`: '${customer.referralId}'}); """ fun createSubscription(msisdn: String) = """ @@ -28,9 +26,9 @@ SET node.msisdn = '$msisdn' SET node.balance = '$balance'; """ -fun addSubscriberToSegment(email: String) = """ +fun addSubscriberToSegment(id: String) = """ MATCH (to:Subscriber) - WHERE to.id IN ['$email'] + WHERE to.id IN ['$id'] WITH to MATCH (from:Segment {id: 'all'}) CREATE (from)-[:segmentToSubscriber]->(to); diff --git a/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/FirebaseExporter.kt b/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/FirebaseExporter.kt index fb0bae674..3dcb37820 100644 --- a/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/FirebaseExporter.kt +++ b/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/FirebaseExporter.kt @@ -1,7 +1,7 @@ package org.ostelco.tools.migration import com.google.firebase.database.FirebaseDatabase -import org.ostelco.prime.model.Subscriber +import org.ostelco.prime.model.Customer import org.ostelco.prime.storage.firebase.EntityStore import org.ostelco.prime.storage.firebase.EntityType import org.ostelco.prime.storage.firebase.FirebaseConfig @@ -9,14 +9,14 @@ import org.ostelco.prime.storage.firebase.FirebaseConfigRegistry fun initFirebase() { FirebaseConfigRegistry.firebaseConfig = FirebaseConfig( - configFile = "../../prime/config/pantel-prod.json", + configFile = "../../prime/config/prime-service-account.json", rootPath = "v2") } // Code moved here from FirebaseStorageSingleton private val balanceEntity = EntityType("balance", Long::class.java) private val subscriptionEntity = EntityType("subscriptions", String::class.java) -private val subscriberEntity = EntityType("subscribers", Subscriber::class.java) +private val subscriberEntity = EntityType("subscribers", Customer::class.java) // FirebaseDatabase.getInstance() will work only if FirebaseStorageSingleton.setupFirebaseInstance() is already executed. private val balanceStore = EntityStore(FirebaseDatabase.getInstance(), balanceEntity) @@ -62,6 +62,6 @@ fun importFromFirebase(action: (String) -> Unit) { subscribers .values .stream() - .map { addSubscriberToSegment(it.email) } + .map { addSubscriberToSegment(it.id) } .forEach { action(it) } } \ No newline at end of file diff --git a/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/MainKt.kt b/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/Main.kt similarity index 98% rename from tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/MainKt.kt rename to tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/Main.kt index d1a32f104..353081da7 100644 --- a/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/MainKt.kt +++ b/tools/neo4j-admin-tools/src/main/kotlin/org/ostelco/tools/migration/Main.kt @@ -4,7 +4,7 @@ import org.neo4j.driver.v1.AccessMode import java.nio.file.Files import java.nio.file.Paths -fun main(args: Array) { +fun main() { // neo4jExporterToCypherFile() cypherFileToNeo4jImporter() } diff --git a/tools/neo4j-admin-tools/src/main/resources/docker-compose.yaml b/tools/neo4j-admin-tools/src/main/resources/docker-compose.yaml index 60199e5e5..e1de7c852 100644 --- a/tools/neo4j-admin-tools/src/main/resources/docker-compose.yaml +++ b/tools/neo4j-admin-tools/src/main/resources/docker-compose.yaml @@ -3,7 +3,7 @@ version: "3.7" services: neo4j: container_name: "neo4j" - image: neo4j:3.4.8-enterprise + image: neo4j:3.4.9-enterprise environment: - NEO4J_AUTH=none - NEO4J_ACCEPT_LICENSE_AGREEMENT=yes diff --git a/tools/neo4j-admin-tools/src/main/resources/init.cypher b/tools/neo4j-admin-tools/src/main/resources/init.cypher index 605f88fe0..de96b9bcb 100644 --- a/tools/neo4j-admin-tools/src/main/resources/init.cypher +++ b/tools/neo4j-admin-tools/src/main/resources/init.cypher @@ -1,48 +1,51 @@ // For country:NO +CREATE (:Region {`id`: 'no', + `name`: 'Norway'}); + CREATE (:Product {`id`: '1GB_0NOK', `presentation/isDefault`: 'true', `presentation/offerLabel`: '', `presentation/priceLabel`: 'Free', - `presentation/productLabel`: '+1GB', + `presentation/productLabel`: '1GB', `price/amount`: '0', `price/currency`: '', - `properties/noOfBytes`: '1_000_000_000', + `properties/noOfBytes`: '1_073_741_824', `sku`: '1GB_0NOK'}); CREATE (:Product {`id`: '1GB_249NOK', `presentation/offerLabel`: 'Default Offer', - `presentation/priceLabel`: '249 NOK', - `presentation/productLabel`: '+1GB', + `presentation/priceLabel`: '249 kr', + `presentation/productLabel`: '1GB', `price/amount`: '24900', `price/currency`: 'NOK', - `properties/noOfBytes`: '1_000_000_000', + `properties/noOfBytes`: '1_073_741_824', `sku`: '1GB_249NOK'}); CREATE (:Product {`id`: '2GB_299NOK', `presentation/offerLabel`: 'Monday Special', - `presentation/priceLabel`: '299 NOK', - `presentation/productLabel`: '+2GB', + `presentation/priceLabel`: '299 kr', + `presentation/productLabel`: '2GB', `price/amount`: '29900', `price/currency`: 'NOK', - `properties/noOfBytes`: '2_000_000_000', + `properties/noOfBytes`: '2_147_483_648', `sku`: '2GB_299NOK'}); CREATE (:Product {`id`: '3GB_349NOK', `presentation/offerLabel`: 'Monday Special', - `presentation/priceLabel`: '349 NOK', - `presentation/productLabel`: '+3GB', + `presentation/priceLabel`: '349 kr', + `presentation/productLabel`: '3GB', `price/amount`: '34900', `price/currency`: 'NOK', - `properties/noOfBytes`: '3_000_000_000', + `properties/noOfBytes`: '3_221_225_472', `sku`: '3GB_349NOK'}); CREATE (:Product {`id`: '5GB_399NOK', `presentation/offerLabel`: 'Weekend Special', - `presentation/priceLabel`: '399 NOK', - `presentation/productLabel`: '+5GB', + `presentation/priceLabel`: '399 kr', + `presentation/productLabel`: '5GB', `price/amount`: '39900', `price/currency`: 'NOK', - `properties/noOfBytes`: '5_000_000_000', + `properties/noOfBytes`: '5_368_709_120', `sku`: '5GB_399NOK'}); CREATE (:Segment {`id`: 'country-no'}); @@ -80,23 +83,26 @@ MATCH (m:Segment {id: 'country-no'}) CREATE (n)-[:OFFERED_TO_SEGMENT]->(m); // For country:SG +CREATE (:Region {`id`: 'sg', + `name`: 'Singapore'}); + CREATE (:Product {`id`: '1GB_1SGD', `presentation/isDefault`: 'true', `presentation/offerLabel`: 'Default Offer', - `presentation/priceLabel`: '1 SGD', - `presentation/productLabel`: '+1GB', + `presentation/priceLabel`: '$1', + `presentation/productLabel`: '1GB', `price/amount`: '100', `price/currency`: 'SGD', - `properties/noOfBytes`: '1_000_000_000', + `properties/noOfBytes`: '1_073_741_824', `sku`: '1GB_1SGD'}); CREATE (:Product {`id`: '3GB_1.5SGD', - `presentation/offerLabel`: 'Default Offer', - `presentation/priceLabel`: '1.5 SGD', - `presentation/productLabel`: '+3GB', + `presentation/offerLabel`: 'Special Offer', + `presentation/priceLabel`: '$1.5', + `presentation/productLabel`: '3GB', `price/amount`: '150', `price/currency`: 'SGD', - `properties/noOfBytes`: '3_000_000_000', + `properties/noOfBytes`: '3_221_225_472', `sku`: '3GB_1.5SGD'}); CREATE (:Segment {`id`: 'country-sg'}); @@ -119,19 +125,19 @@ MATCH (m:Segment {id: 'country-sg'}) CREATE (n)-[:OFFERED_TO_SEGMENT]->(m); // Generic -CREATE (:Product {`id`: '100MB_FREE_ON_JOINING', +CREATE (:Product {`id`: '2GB_FREE_ON_JOINING', `presentation/priceLabel`: 'Free', - `presentation/productLabel`: '100MB Welcome Pack', + `presentation/productLabel`: '2GB Welcome Pack', `price/amount`: '0', `price/currency`: '', - `properties/noOfBytes`: '100_000_000', - `sku`: '100MB_FREE_ON_JOINING'}); + `properties/noOfBytes`: '2_147_483_648', + `sku`: '2GB_FREE_ON_JOINING'}); CREATE (:Product {`id`: '1GB_FREE_ON_REFERRED', `presentation/priceLabel`: 'Free', `presentation/productLabel`: '1GB Referral Pack', `price/amount`: '0', `price/currency`: '', - `properties/noOfBytes`: '1_000_000_000', + `properties/noOfBytes`: '1_073_741_824', `sku`: '1GB_FREE_ON_REFERRED'});